From 568dc76195a49dbb56bf1a0445003bc9eda283af Mon Sep 17 00:00:00 2001
From: Camille Simiand <camille.simiand@tetras-libre.fr>
Date: Tue, 8 Mar 2022 17:42:48 +0100
Subject: [PATCH] Add unique email constraint + translations

---
 src/Controller/UserController.php             | 80 ++++++++++++++-----
 src/Entity/PendingEmailAddress.php            |  2 +
 src/Form/EditUserProfileFormType.php          | 29 ++++++-
 .../PendingEmailAddressRepository.php         | 28 +++++++
 src/Repository/UserRepository.php             | 15 ++++
 src/Security/EmailVerifier.php                |  4 +-
 templates/user/update_email.html.twig         |  7 +-
 translations/messages.en.yaml                 |  9 ++-
 translations/messages.fr.yaml                 |  3 +-
 translations/validators.en.yaml               | 10 ++-
 translations/validators.fr.yaml               | 10 ++-
 11 files changed, 165 insertions(+), 32 deletions(-)

diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php
index a3afb46..f10d1eb 100755
--- a/src/Controller/UserController.php
+++ b/src/Controller/UserController.php
@@ -6,12 +6,15 @@ use App\Entity\PendingEmailAddress;
 use App\Entity\User;
 use App\Form\EditPasswordFormType;
 use App\Form\EditUserProfileFormType;
-use App\Security\EmailVerifier;
+use App\Repository\PendingEmailAddressRepository;
+use App\Repository\UserRepository;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bridge\Twig\Mime\TemplatedEmail;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mime\Address;
 use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
 use Symfony\Component\Routing\Annotation\Route;
 use Symfony\Contracts\Translation\TranslatorInterface;
@@ -20,7 +23,9 @@ class UserController extends AbstractController
 {
     public function __construct(
         private EntityManagerInterface $entity_manager,
-        private TranslatorInterface $translator
+        private TranslatorInterface $translator,
+        private PendingEmailAddressRepository $pending_email_address_repository,
+        private UserRepository $user_repository
     ) {
     }
 
@@ -39,25 +44,31 @@ class UserController extends AbstractController
     }
 
     #[Route('/edit_profile', name:'edit_profile')]
-    public function editProfile(Request $request, EmailVerifier $email_verifier): Response
-    {
+    public function editProfile(
+        Request $request,
+        MailerInterface $mailer
+    ): Response {
         $current_user = $this->getUser();
 
         if (! $current_user instanceof User) {
             return $this->redirectToRoute('app_logout');
         }
 
+        $users_emails = $this->user_repository->getAllEmails();
+        $pending_email_addresses = $this->pending_email_address_repository->getAllEmails();
+
         $form = $this->createForm(
             EditUserProfileFormType::class,
             $current_user,
-            ['current_email_address' => $current_user->getEmail()]
+            [
+                'current_email_address' => $current_user->getEmail(),
+                'users_emails' => $users_emails,
+                'pending_emails' => $pending_email_addresses
+            ]
         );
         $form->handleRequest($request);
 
         if ($form->isSubmitted() && $form->isValid()) {
-            $this->entity_manager->persist($current_user);
-            $this->entity_manager->flush();
-
             if ($current_user->getEmail() !== $form->get('email')->getData()) {
                 $pending_email_address = new PendingEmailAddress();
                 $pending_email_address->setEmail($form->get('email')->getData());
@@ -65,15 +76,14 @@ class UserController extends AbstractController
                 $this->entity_manager->persist($pending_email_address);
                 $this->entity_manager->flush();
 
-                $email_verifier->sendEmailConfirmation(
-                    'app_verify_email',
-                    $pending_email_address->getUser()->getId(),
-                    $pending_email_address->getEmail(),
-                    (new TemplatedEmail())
-                        ->to($pending_email_address->getEmail())
-                        ->subject('Please Confirm your Email')
-                        ->htmlTemplate('user/update_email.html.twig')
-                );
+                $email = (new TemplatedEmail())
+                    ->to(new Address($pending_email_address->getEmail()))
+                    ->subject('Please Confirm your Email')
+                    ->htmlTemplate('user/update_email.html.twig')
+                    ->context([
+                        'expiration_date' => new \DateTime('+1 day')
+                    ]);
+                $mailer->send($email);
 
                 $this->addFlash(
                     'warning',
@@ -95,6 +105,9 @@ class UserController extends AbstractController
             return $this->redirectToRoute('show_profile');
         }
 
+        $this->entity_manager->persist($current_user);
+        $this->entity_manager->flush();
+
         return $this->renderForm('user/edit_profile.html.twig', [
             'editUserProfileForm' => $form
         ]);
@@ -103,7 +116,7 @@ class UserController extends AbstractController
     #[Route('/edit_password', name:'edit_password')]
     public function editPassword(
         Request $request,
-        UserPasswordHasherInterface $user_password_hasher,
+        UserPasswordHasherInterface $user_password_hasher
     ): Response {
         $form = $this->createForm(EditPasswordFormType::class);
         $form->handleRequest($request);
@@ -132,4 +145,35 @@ class UserController extends AbstractController
             'editPasswordForm' => $form
         ]);
     }
+
+    #[Route('/edit/email/', name:'verify_new_email_address')]
+    public function verifyNewEmailAddress(): Response
+    {
+        $current_user = $this->getUser();
+
+        if (! $current_user instanceof User) {
+            return $this->redirectToRoute('app_logout');
+        }
+
+        $pending_email_address = $this->pending_email_address_repository->findOneBy(['user' => $current_user->getId()]);
+        if (! $pending_email_address instanceof PendingEmailAddress) {
+            throw new \Exception('Not found');
+        }
+
+        $current_user->setEmail($pending_email_address->getEmail());
+        $this->entity_manager->persist($current_user);
+
+        $this->entity_manager->remove($pending_email_address);
+        $this->entity_manager->flush();
+
+        $this->addFlash(
+            'success',
+            $this->translator->trans(
+                'user.edit.email.success',
+                ['new_email_address' => $pending_email_address->getEmail()]
+            )
+        );
+
+        return $this->redirectToRoute('show_profile');
+    }
 }
diff --git a/src/Entity/PendingEmailAddress.php b/src/Entity/PendingEmailAddress.php
index 3f9a9f3..3a29a22 100644
--- a/src/Entity/PendingEmailAddress.php
+++ b/src/Entity/PendingEmailAddress.php
@@ -4,10 +4,12 @@ namespace App\Entity;
 
 use App\Repository\PendingEmailAddressRepository;
 use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
 use Symfony\Component\Validator\Constraints as Assert;
 
 #[ORM\Entity(repositoryClass:PendingEmailAddressRepository::class)]
 #[ORM\Table(name:'`pending_email_address`')]
+#[UniqueEntity(fields: ['user', 'email'], message: 'pending_email_address.email.unique', errorPath: 'email')]
 class PendingEmailAddress
 {
     #[ORM\Id]
diff --git a/src/Form/EditUserProfileFormType.php b/src/Form/EditUserProfileFormType.php
index 0c41c79..0f0a03b 100755
--- a/src/Form/EditUserProfileFormType.php
+++ b/src/Form/EditUserProfileFormType.php
@@ -13,6 +13,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
 use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
 use Symfony\Component\Validator\Constraints\NotBlank;
 use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\Context\ExecutionContextInterface;
 
 class EditUserProfileFormType extends AbstractType
 {
@@ -44,11 +45,29 @@ class EditUserProfileFormType extends AbstractType
                     'data' => $options['current_email_address'],
                     'constraints' => [
                         new NotBlank(['message' => 'email.not_blank']),
-                        new Assert\Email()
+                        new Assert\Email(),
+                        new Assert\Callback(
+                            ['callback' => static function (
+                                ?string $value,
+                                ExecutionContextInterface $context
+                            ) use ($options) {
+                                if (
+                                    $value !== $options['current_email_address'] &&
+                                    (in_array($value, $options['users_emails']) ||
+                                        in_array($value, $options['pending_emails']))
+                                ) {
+                                    $context
+                                        ->buildViolation(message: 'user.email.unique')
+                                        ->addViolation()
+                                    ;
+                                }
+                            }
+                            ]
+                        )
                     ],
                     'label' => 'general.email',
                     'empty_data' => '',
-                    'mapped' => false,
+                    'mapped' => false
                 ]
             )
             ->add(
@@ -76,7 +95,9 @@ class EditUserProfileFormType extends AbstractType
     {
         $resolver->setDefaults([
             'data_class' => User::class,
-            'current_email_address' => ''
-        ]);
+            'current_email_address' => '',
+            'users_emails' => [],
+            'pending_emails' => []
+            ]);
     }
 }
diff --git a/src/Repository/PendingEmailAddressRepository.php b/src/Repository/PendingEmailAddressRepository.php
index 78585ce..dbc3379 100644
--- a/src/Repository/PendingEmailAddressRepository.php
+++ b/src/Repository/PendingEmailAddressRepository.php
@@ -7,6 +7,8 @@ use App\Entity\User;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;
 
+use function PHPUnit\Framework\assertInstanceOf;
+
 /**
  * @method PendingEmailAddress|null find($id, $lockMode = null, $lockVersion = null)
  * @method PendingEmailAddress|null findOneBy(array $criteria, array $orderBy = null)
@@ -20,4 +22,30 @@ class PendingEmailAddressRepository extends ServiceEntityRepository
     {
         parent::__construct($registry, PendingEmailAddress::class);
     }
+
+    public function findOneByEmail(string $value): ?User
+    {
+        return $this->createQueryBuilder('u')
+            ->andWhere('u.email = :val')
+            ->setParameter('val', $value)
+            ->getQuery()
+            ->getOneOrNullResult()
+            ;
+    }
+
+    /**
+     * @return string[]
+     */
+    public function getAllEmails(): array
+    {
+        $all_pending_email_addresses = $this->findAll();
+        $all_emails = [];
+
+        foreach ($all_pending_email_addresses as $pending_email_address) {
+            assertInstanceOf(PendingEmailAddress::class, $pending_email_address);
+            $all_emails[] = $pending_email_address->getEmail();
+        }
+
+        return $all_emails;
+    }
 }
diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php
index 147f8c5..476ef81 100755
--- a/src/Repository/UserRepository.php
+++ b/src/Repository/UserRepository.php
@@ -72,4 +72,19 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
             ->getQuery()
             ->getResult();
     }
+
+    /**
+     * @return string[]
+     */
+    public function getAllEmails(): array
+    {
+        $all_users = $this->findAll();
+        $all_emails = [];
+
+        foreach ($all_users as $user) {
+            $all_emails[] = $user->getEmail();
+        }
+
+        return $all_emails;
+    }
 }
diff --git a/src/Security/EmailVerifier.php b/src/Security/EmailVerifier.php
index ea4389f..aa910c7 100755
--- a/src/Security/EmailVerifier.php
+++ b/src/Security/EmailVerifier.php
@@ -27,13 +27,13 @@ class EmailVerifier
     }
 
     public function sendEmailConfirmation(
-        string $verifyEmailRouteName,
+        string $verify_email_route_name,
         int $user_id,
         string $email_address,
         TemplatedEmail $email
     ): void {
         $signatureComponents = $this->verifyEmailHelper->generateSignature(
-            $verifyEmailRouteName,
+            $verify_email_route_name,
             (string) $user_id,
             $email_address,
             ['id' => $user_id]
diff --git a/templates/user/update_email.html.twig b/templates/user/update_email.html.twig
index e292b58..082e02f 100644
--- a/templates/user/update_email.html.twig
+++ b/templates/user/update_email.html.twig
@@ -5,11 +5,12 @@
 <p>
     {{ 'user.edit.email.text'|trans }}:
     <br><br>
-    <a href="{{ signedUrl }}">
+    <a href="{{ path('verify_new_email_address') }}">
         {{ 'user.edit.email.confirm_email'|trans }}
     </a>
-    {{ 'general.link_expire'|trans({'%expirationDuration%':
-        expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle')}) }}
+    {{ 'general.link_expire'|trans
+
+    }}
 </p>
 
 <p>
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml
index 215c4aa..d3c7ea7 100644
--- a/translations/messages.en.yaml
+++ b/translations/messages.en.yaml
@@ -122,8 +122,9 @@ user:
   edit:
     email:
       title: Hi! Please confirm your email
-      text: Please click the following link to update your user account and for now on use this email address.
+      text: Please click the following link to update your user account and for now on use this email address
       confirm_email: Confirm my email
+      success: Your MemoRekall account is now related to your new email address new_email_address
 
 editors:
     title: Editors
@@ -199,4 +200,8 @@ groups:
     success: Group group_name removed successfully
   filter:
     label: Filter by group
-    no_filter: Show all
\ No newline at end of file
+    no_filter: Show all
+
+pending_email_address:
+  email:
+    unique:
\ No newline at end of file
diff --git a/translations/messages.fr.yaml b/translations/messages.fr.yaml
index 63e962c..ec1f912 100644
--- a/translations/messages.fr.yaml
+++ b/translations/messages.fr.yaml
@@ -121,7 +121,8 @@ user:
     email:
       title: Bonjour ! Veuillez confirmer votre adresse email
       text: Veuillez cliquer sur le lien suivant pour mettre à jour votre profil utilisateur et utiliser dorénavant cette adresse e-mail.
-    confirm_email: Confirmer mon adresse e-mail
+      confirm_email: Confirmer mon adresse e-mail
+      success: Votre compte MemoRekall est dorénavant relié à cette nouvelle adresse e-mail new_email_address
 
 editors:
     title: Editeurs d'une capsule
diff --git a/translations/validators.en.yaml b/translations/validators.en.yaml
index 0b5aa0f..9fc33d6 100644
--- a/translations/validators.en.yaml
+++ b/translations/validators.en.yaml
@@ -27,4 +27,12 @@ capsule:
 group:
   name:
     not_blank: Please enter a group name
-    unique: There is already a group with this name
\ No newline at end of file
+    unique: There is already a group with this name
+
+user:
+  email:
+    unique: Email address already used
+
+pending_email_address:
+  email:
+    unique: Currently waiting for validation to use this new email address for your account
\ No newline at end of file
diff --git a/translations/validators.fr.yaml b/translations/validators.fr.yaml
index 90ce8ff..56d0c6a 100644
--- a/translations/validators.fr.yaml
+++ b/translations/validators.fr.yaml
@@ -27,4 +27,12 @@ capsule:
 group:
   name:
     not_blank: Veuillez saisir le nom du groupe
-    unique: Il existe déjà un groupe avec ce nom
\ No newline at end of file
+    unique: Il existe déjà un groupe avec ce nom
+
+user:
+  email:
+    unique: Il existe déjà un compte relié à cette adresse e-mail
+
+pending_email_address:
+  email:
+    unique: Cette adresse e-mail est en attente de validation pour votre compte
\ No newline at end of file
-- 
GitLab