diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 99193e3b5..f10794f48 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -51,3 +51,6 @@ security: # this is a catch-all for the admin area # additional security lives in the controllers - { path: '^/(%app_locales%)/admin', roles: ROLE_ADMIN } + + role_hierarchy: + ROLE_ADMIN: ROLE_USER diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 000000000..4fea4fbbf --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Controller; + +use App\Form\Type\ChangePasswordType; +use App\Form\UserType; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; + +/** + * Controller used to manage current user. + * + * @Route("/profile") + * @Security("has_role('ROLE_USER')") + * + * @author Romain Monteil + */ +class UserController extends AbstractController +{ + /** + * @Route("/edit", methods={"GET", "POST"}, name="user_edit") + */ + public function edit(Request $request): Response + { + $user = $this->getUser(); + + $form = $this->createForm(UserType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->getDoctrine()->getManager()->flush(); + + $this->addFlash('success', 'user.updated_successfully'); + + return $this->redirectToRoute('user_edit'); + } + + return $this->render('user/edit.html.twig', [ + 'user' => $user, + 'form' => $form->createView(), + ]); + } + + /** + * @Route("/change-password", methods={"GET", "POST"}, name="user_change_password") + */ + public function changePassword(Request $request, UserPasswordEncoderInterface $encoder): Response + { + $user = $this->getUser(); + + $form = $this->createForm(ChangePasswordType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user->setPassword($encoder->encodePassword($user, $form->get('newPassword')->getData())); + + $this->getDoctrine()->getManager()->flush(); + + return $this->redirectToRoute('security_logout'); + } + + return $this->render('user/change_password.html.twig', [ + 'form' => $form->createView(), + ]); + } +} diff --git a/src/Form/Type/ChangePasswordType.php b/src/Form/Type/ChangePasswordType.php new file mode 100644 index 000000000..89956ba55 --- /dev/null +++ b/src/Form/Type/ChangePasswordType.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Form\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\Core\Type\RepeatedType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder; +use Symfony\Component\Security\Core\Validator\Constraints\UserPassword; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; + +/** + * Defines the custom form field type used to change user's password. + * + * @author Romain Monteil + */ +class ChangePasswordType extends AbstractType +{ + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('currentPassword', PasswordType::class, [ + 'constraints' => [ + new UserPassword(), + ], + 'label' => 'label.current_password', + 'attr' => [ + 'autocomplete' => 'off', + ], + ]) + ->add('newPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'constraints' => [ + new NotBlank(), + new Length([ + 'min' => 5, + 'max' => BCryptPasswordEncoder::MAX_PASSWORD_LENGTH, + ]), + ], + 'first_options' => [ + 'label' => 'label.new_password', + ], + 'second_options' => [ + 'label' => 'label.new_password_confirm', + ], + ]) + ; + } +} diff --git a/src/Form/UserType.php b/src/Form/UserType.php new file mode 100644 index 000000000..835d6ad57 --- /dev/null +++ b/src/Form/UserType.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Form; + +use App\Entity\User; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Defines the form used to edit an user. + * + * @author Romain Monteil + */ +class UserType extends AbstractType +{ + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + // For the full reference of options defined by each form field type + // see https://symfony.com/doc/current/reference/forms/types.html + + // By default, form fields include the 'required' attribute, which enables + // the client-side form validation. This means that you can't test the + // server-side validation errors from the browser. To temporarily disable + // this validation, set the 'required' attribute to 'false': + // $builder->add('title', null, ['required' => false, ...]); + + $builder + ->add('username', TextType::class, [ + 'label' => 'label.username', + 'disabled' => true, + ]) + ->add('fullName', TextType::class, [ + 'label' => 'label.fullname', + ]) + ->add('email', EmailType::class, [ + 'label' => 'label.email', + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index 2b039af36..76ca74241 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -61,10 +61,25 @@ {% if app.user %} -
  • - - {{ 'menu.logout'|trans }} +
  • {% endif %} diff --git a/templates/user/change_password.html.twig b/templates/user/change_password.html.twig new file mode 100644 index 000000000..82421c75d --- /dev/null +++ b/templates/user/change_password.html.twig @@ -0,0 +1,29 @@ +{% extends 'base.html.twig' %} + +{% block body_id 'user_password' %} + +{% block main %} +

    {{ 'title.change_password'|trans }}

    + + + + {{ form_start(form) }} + {{ form_widget(form) }} + + + {{ form_end(form) }} +{% endblock %} + +{% block sidebar %} + + + {{ parent() }} + + {{ show_source_code(_self) }} +{% endblock %} diff --git a/templates/user/edit.html.twig b/templates/user/edit.html.twig new file mode 100644 index 000000000..126635e39 --- /dev/null +++ b/templates/user/edit.html.twig @@ -0,0 +1,27 @@ +{% extends 'base.html.twig' %} + +{% block body_id 'user_edit' %} + +{% block main %} +

    {{ 'title.edit_user'|trans }}

    + + {{ form_start(form) }} + {{ form_widget(form) }} + + + {{ form_end(form) }} +{% endblock %} + +{% block sidebar %} + + + {{ parent() }} + + {{ show_source_code(_self) }} +{% endblock %} diff --git a/tests/Controller/UserControllerTest.php b/tests/Controller/UserControllerTest.php new file mode 100644 index 000000000..e7e4a452e --- /dev/null +++ b/tests/Controller/UserControllerTest.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests\Controller; + +use App\Entity\User; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\Response; + +/** + * Functional test for the controllers defined inside the UserController used + * for managing the current logged user. + * + * See https://symfony.com/doc/current/book/testing.html#functional-tests + * + * Whenever you test resources protected by a firewall, consider using the + * technique explained in: + * https://symfony.com/doc/current/cookbook/testing/http_authentication.html + * + * Execute the application tests using this command (requires PHPUnit to be installed): + * + * $ cd your-symfony-project/ + * $ ./vendor/bin/phpunit + */ +class UserControllerTest extends WebTestCase +{ + /** + * @dataProvider getUrlsForAnonymousUsers + */ + public function testAccessDeniedForAnonymousUsers(string $httpMethod, string $url) + { + $client = static::createClient(); + $client->request($httpMethod, $url); + + $response = $client->getResponse(); + $this->assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertSame( + 'http://localhost/en/login', + $response->getTargetUrl(), + sprintf('The %s secure URL redirects to the login form.', $url) + ); + } + + public function getUrlsForAnonymousUsers() + { + yield ['GET', '/en/profile/edit']; + yield ['GET', '/en/profile/change-password']; + } + + public function testEditUser() + { + $newUserEmail = 'admin_jane@symfony.com'; + + $client = static::createClient([], [ + 'PHP_AUTH_USER' => 'jane_admin', + 'PHP_AUTH_PW' => 'kitten', + ]); + $crawler = $client->request('GET', '/en/profile/edit'); + $form = $crawler->selectButton('Save changes')->form([ + 'user[email]' => $newUserEmail, + ]); + $client->submit($form); + + $this->assertSame(Response::HTTP_FOUND, $client->getResponse()->getStatusCode()); + + /** @var User $user */ + $user = $client->getContainer()->get('doctrine')->getRepository(User::class)->findOneBy([ + 'email' => $newUserEmail, + ]); + $this->assertNotNull($user); + $this->assertSame($newUserEmail, $user->getEmail()); + } + + public function testChangePassword() + { + $newUserPassword = 'new-password'; + + $client = static::createClient([], [ + 'PHP_AUTH_USER' => 'jane_admin', + 'PHP_AUTH_PW' => 'kitten', + ]); + $crawler = $client->request('GET', '/en/profile/change-password'); + $form = $crawler->selectButton('Save changes')->form([ + 'change_password[currentPassword]' => 'kitten', + 'change_password[newPassword][first]' => $newUserPassword, + 'change_password[newPassword][second]' => $newUserPassword, + ]); + $client->submit($form); + + $response = $client->getResponse(); + $this->assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertSame( + '/en/logout', + $response->getTargetUrl(), + 'Changing password logout the user.' + ); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 098699b40..36265c4ab 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -92,6 +92,14 @@ title.comment_error There was an error publishing your comment + + title.edit_user + Edit user + + + title.change_password + Change password + action.show @@ -169,6 +177,14 @@ action.browse_admin Browse backend + + action.edit_user + Edit user + + + action.change_password + Change password + label.title @@ -186,10 +202,30 @@ label.username Username + + label.fullname + Fullname + + + label.email + Email + label.password Password + + label.current_password + Current password + + + label.new_password + New password + + + label.new_password_confirm + Confirm password + label.role Role @@ -247,6 +283,10 @@ menu.admin Backend + + menu.user + Account + menu.logout Logout @@ -301,6 +341,11 @@ No results found + + user.updated_successfully + User updated successfully! + + notification.comment_created Your post received a comment! @@ -351,6 +396,11 @@ Symfony doc.]]> + + info.change_password + After changing your password, you will be logged out of the application. + + rss.title Symfony Demo blog diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 7b3090209..4c8708327 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -92,6 +92,14 @@ title.comment_error Il y a eu une erreur lors de la publication de votre commentaire. + + title.edit_user + Modifier l'utilisateur + + + title.change_password + Modifier le mot de passe + action.show @@ -169,6 +177,14 @@ action.browse_admin Naviguer sur l'admin + + action.edit_user + Modifier l'utilisateur + + + action.change_password + Modifier le mot de passe + label.title @@ -186,10 +202,30 @@ label.username Identifiant + + label.fullname + Nom complet + + + label.email + Mail + label.password Mot de passe + + label.current_password + Mot de passe actuel + + + label.new_password + Nouveau mot de passe + + + label.new_password_confirm + Confirmer le mot de passe + label.role Rôle @@ -247,6 +283,10 @@ menu.admin Admin + + menu.user + Compte + menu.logout Déconnexion @@ -301,6 +341,11 @@ Aucun résultat + + user.updated_successfully + Informations mises à jour avec succès ! + + notification.comment_created Votre article a reçu un commentaire ! @@ -351,6 +396,11 @@ documentation de Symfony.]]> + + info.change_password + Après avoir modifié votre mot de passe, vous serez déconnecté de l'application. + + rss.title Blog de démo Symfony