diff --git a/Controller/AbstractController.php b/Controller/AbstractController.php index 22ec1043..4fec7637 100644 --- a/Controller/AbstractController.php +++ b/Controller/AbstractController.php @@ -113,7 +113,7 @@ protected function checkAutoLogin($type) * * @return Response */ - protected function renderTemplate($type, $data) + protected function renderTemplate($type, $data = []) { return $this->render( $this->getCommunityManager($this->getWebspaceKey())->getConfigTypeProperty( diff --git a/Controller/EmailConfirmationController.php b/Controller/EmailConfirmationController.php new file mode 100644 index 00000000..224eb405 --- /dev/null +++ b/Controller/EmailConfirmationController.php @@ -0,0 +1,50 @@ +get('sulu_community.email_confirmation.repository'); + + $success = false; + $token = $repository->findByToken($request->get('token')); + + if ($token !== null) { + $user = $token->getUser(); + $user->setEmail($user->getContact()->getMainEmail()); + $this->get('doctrine.orm.entity_manager')->remove($token); + $this->saveEntities(); + + $success = true; + } + + return $this->renderTemplate(self::TYPE, ['success' => $success]); + } +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 461def76..cb9e2260 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -46,6 +46,7 @@ class Configuration implements ConfigurationInterface const TYPE_BLACKLIST_CONFIRMED = 'blacklist_confirmed'; const TYPE_BLACKLIST_DENIED = 'blacklist_denied'; const TYPE_PROFILE = 'profile'; + const TYPE_EMAIL_CONFIRMATION = 'email_confirmation'; public static $TYPES = [ self::TYPE_LOGIN, @@ -139,6 +140,22 @@ public function getConfigTreeBuilder() ->end() ->end() ->end() + // Email change + ->arrayNode(self::TYPE_EMAIL_CONFIRMATION) + ->addDefaultsIfNotSet() + ->children() + // Email change configuration + ->scalarNode(self::TEMPLATE)->defaultValue('SuluCommunityBundle:EmailConfirmation:success.html.twig')->end() + ->arrayNode(self::EMAIL) + ->addDefaultsIfNotSet() + ->children() + ->scalarNode(self::EMAIL_SUBJECT)->defaultValue('E-Mail change')->end() + ->scalarNode(self::EMAIL_ADMIN_TEMPLATE)->defaultValue(null)->end() + ->scalarNode(self::EMAIL_USER_TEMPLATE)->defaultValue('SuluCommunityBundle:EmailConfirmation:email.html.twig')->end() + ->end() + ->end() + ->end() + ->end() // Confirmation ->arrayNode(self::TYPE_CONFIRMATION) ->addDefaultsIfNotSet() diff --git a/Entity/EmailConfirmationToken.php b/Entity/EmailConfirmationToken.php new file mode 100644 index 00000000..82fc7db5 --- /dev/null +++ b/Entity/EmailConfirmationToken.php @@ -0,0 +1,87 @@ +user = $user; + } + + /** + * Returns id. + * + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * Returns token. + * + * @return string + */ + public function getToken() + { + return $this->token; + } + + /** + * Set token. + * + * @param string $token + * + * @return $this + */ + public function setToken($token) + { + $this->token = $token; + + return $this; + } + + /** + * Returns user. + * + * @return UserInterface + */ + public function getUser() + { + return $this->user; + } +} diff --git a/Entity/EmailConfirmationTokenRepository.php b/Entity/EmailConfirmationTokenRepository.php new file mode 100644 index 00000000..7ad7ec4f --- /dev/null +++ b/Entity/EmailConfirmationTokenRepository.php @@ -0,0 +1,59 @@ +findOneBy(['token' => $token]); + } catch (NonUniqueResultException $e) { + return; + } catch (NoResultException $e) { + return; + } + } + + /** + * Return email-confirmation for given token. + * + * @param UserInterface $user + * + * @return EmailConfirmationToken + */ + public function findByUser($user) + { + try { + return $this->findOneBy(['user' => $user]); + } catch (NonUniqueResultException $e) { + return; + } catch (NoResultException $e) { + return; + } + } +} diff --git a/EventListener/EmailConfirmationListener.php b/EventListener/EmailConfirmationListener.php new file mode 100644 index 00000000..53b52b90 --- /dev/null +++ b/EventListener/EmailConfirmationListener.php @@ -0,0 +1,99 @@ +mailFactory = $mailFactory; + $this->entityManager = $entityManager; + $this->emailConformationRepository = $emailConformationRepository; + $this->tokenGenerator = $tokenGenerator; + } + + /** + * Send confirmation-email if email-address has changed. + * + * @param CommunityEvent $event + */ + public function sendConfirmationOnEmailChange(CommunityEvent $event) + { + $user = $event->getUser(); + if ($user->getEmail() === $user->getContact()->getMainEmail()) { + return; + } + + $entity = $this->emailConformationRepository->findByUser($user); + $token = $this->tokenGenerator->generateToken(); + if (null === $entity) { + $entity = new EmailConfirmationToken($user); + $this->entityManager->persist($entity); + } + + $entity->setToken($token); + $this->entityManager->flush(); + + $this->mailFactory->sendEmails( + Mail::create( + $event->getConfigProperty(Configuration::EMAIL_FROM), + $event->getConfigProperty(Configuration::EMAIL_TO), + $event->getConfigTypeProperty(Configuration::TYPE_EMAIL_CONFIRMATION, Configuration::EMAIL) + )->setUserEmail($user->getContact()->getMainEmail()), + $user, + ['token' => $entity->getToken()] + ); + } +} diff --git a/Form/Type/ProfileContactType.php b/Form/Type/ProfileContactType.php index 6edab7ae..b19fc420 100644 --- a/Form/Type/ProfileContactType.php +++ b/Form/Type/ProfileContactType.php @@ -15,6 +15,7 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -45,6 +46,7 @@ public function buildForm(FormBuilderInterface $builder, array $options) $builder->add('first_name', TextType::class); $builder->add('last_name', TextType::class); + $builder->add('main_email', EmailType::class); $builder->add('avatar', FileType::class, ['mapped' => false, 'required' => false]); $builder->add( diff --git a/Form/Type/ProfileNoteType.php b/Form/Type/ProfileNoteType.php index fede67e7..99e32e2c 100644 --- a/Form/Type/ProfileNoteType.php +++ b/Form/Type/ProfileNoteType.php @@ -13,10 +13,14 @@ use Sulu\Bundle\ContactBundle\Entity\Note; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +/** + * Profile note form. + */ class ProfileNoteType extends AbstractType { /** @@ -25,6 +29,20 @@ class ProfileNoteType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('value', TextType::class, ['required' => false, 'label' => 'Note']); + $builder->get('value')->addViewTransformer( + new CallbackTransformer( + function ($value) { + return $value; + }, + function ($value) { + if ($value === null) { + return ''; + } + + return $value; + } + ) + ); } /** diff --git a/Mail/Mail.php b/Mail/Mail.php index c9965077..2d5ac436 100644 --- a/Mail/Mail.php +++ b/Mail/Mail.php @@ -48,6 +48,11 @@ public static function create($from, $to, $config) */ private $to; + /** + * @var string + */ + private $userEmail; + /** * @var string */ @@ -128,4 +133,29 @@ public function getAdminTemplate() { return $this->adminTemplate; } + + /** + * Returns user-email. + * + * @return string + */ + public function getUserEmail() + { + return $this->userEmail; + } + + /** + * Set user-email. + * This setting overwrite the user-email. + * + * @param string $userEmail + * + * @return $this + */ + public function setUserEmail($userEmail) + { + $this->userEmail = $userEmail; + + return $this; + } } diff --git a/Mail/MailFactory.php b/Mail/MailFactory.php index 515e15a0..b6f15552 100644 --- a/Mail/MailFactory.php +++ b/Mail/MailFactory.php @@ -53,6 +53,9 @@ public function __construct(\Swift_Mailer $mailer, EngineInterface $engine, Tran public function sendEmails(Mail $mail, BaseUser $user, $parameters = []) { $email = $user->getEmail(); + if ($mail->getUserEmail()) { + $email = $mail->getUserEmail(); + } $data = array_merge($parameters, ['user' => $user]); // Send User Email diff --git a/Manager/UserManager.php b/Manager/UserManager.php index 647d1706..75392b0b 100644 --- a/Manager/UserManager.php +++ b/Manager/UserManager.php @@ -103,6 +103,8 @@ public function createUser(User $user, $webspaceKey, $roleName) $contact->setLastName(''); } + $contact->setMainEmail($user->getEmail()); + // Create and Add User Role $userRole = $this->createUserRole($user, $webspaceKey, $roleName); $user->addUserRole($userRole); diff --git a/Resources/config/doctrine/BlacklistItem.orm.xml b/Resources/config/doctrine/BlacklistItem.orm.xml index 58a4c8a3..cdb6d1d1 100644 --- a/Resources/config/doctrine/BlacklistItem.orm.xml +++ b/Resources/config/doctrine/BlacklistItem.orm.xml @@ -4,6 +4,10 @@ xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> + + + + diff --git a/Resources/config/doctrine/BlacklistUser.orm.xml b/Resources/config/doctrine/BlacklistUser.orm.xml index 905cb060..7b3af992 100644 --- a/Resources/config/doctrine/BlacklistUser.orm.xml +++ b/Resources/config/doctrine/BlacklistUser.orm.xml @@ -4,6 +4,10 @@ xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> + + + + diff --git a/Resources/config/doctrine/EmailConfirmationToken.orm.xml b/Resources/config/doctrine/EmailConfirmationToken.orm.xml new file mode 100644 index 00000000..b4d25bf3 --- /dev/null +++ b/Resources/config/doctrine/EmailConfirmationToken.orm.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/Resources/config/routing_website.xml b/Resources/config/routing_website.xml index ef6614f0..fa7f0f77 100644 --- a/Resources/config/routing_website.xml +++ b/Resources/config/routing_website.xml @@ -44,5 +44,9 @@ SuluCommunityBundle:Profile:index + + SuluCommunityBundle:EmailConfirmation:index + + diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 7bd84eaf..31f200fa 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -39,7 +39,7 @@ - + @@ -68,7 +68,7 @@ priority="51"/> - + @@ -113,6 +113,24 @@ SuluCommunityBundle:BlacklistUser + + + + + + + + + + + + + SuluCommunityBundle:EmailConfirmationToken + + diff --git a/Resources/views/EmailConfirmation/email.html.twig b/Resources/views/EmailConfirmation/email.html.twig new file mode 100644 index 00000000..508260c9 --- /dev/null +++ b/Resources/views/EmailConfirmation/email.html.twig @@ -0,0 +1,2 @@ +{% set confirmUrl = url('sulu_community.email_confirmation', { token: token }) %} +{{ confirmUrl }} diff --git a/Resources/views/EmailConfirmation/success.html.twig b/Resources/views/EmailConfirmation/success.html.twig new file mode 100644 index 00000000..56ac778a --- /dev/null +++ b/Resources/views/EmailConfirmation/success.html.twig @@ -0,0 +1,15 @@ +{% extends "master.html.twig" %} + +{% block content %} +

{{ 'Profile'|trans }}

+ + {% if success %} +

+ {{ 'email_confirmation_finished'|trans }} +

+ {% else %} +

+ {{ 'email_confirmation_failed'|trans }} +

+ {% endif %} +{% endblock %} diff --git a/Resources/views/Profile/form.html.twig b/Resources/views/Profile/form.html.twig index a18eb7f5..9d460ced 100644 --- a/Resources/views/Profile/form.html.twig +++ b/Resources/views/Profile/form.html.twig @@ -1,5 +1,17 @@ {% extends "master.html.twig" %} +{% form_theme form _self %} + +{% block email_widget %} + {% spaceless %} + + {{ dump() }} + + {{ block('form_widget') }} + + {% endspaceless %} +{% endblock %} + {% block content %}

{{ 'Profile'|trans }}

diff --git a/Tests/Functional/Controller/EmailConfirmationControllerTest.php b/Tests/Functional/Controller/EmailConfirmationControllerTest.php new file mode 100644 index 00000000..ea134b26 --- /dev/null +++ b/Tests/Functional/Controller/EmailConfirmationControllerTest.php @@ -0,0 +1,107 @@ +purgeDatabase(); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $this->getEntityManager(); + + $contact = new Contact(); + $contact->setMainEmail('new@sulu.io'); + $contact->setFirstName('Hikaru'); + $contact->setLastName('Sulu'); + + $entityManager->persist($contact); + $entityManager->flush(); + + $this->user = new User(); + $this->user->setEmail('test@sulu.io'); + $this->user->setUsername('test'); + $this->user->setPassword('test'); + $this->user->setSalt('test'); + $this->user->setLocale('de'); + $this->user->setContact($contact); + + $token = new EmailConfirmationToken($this->user); + $token->setToken('123-123-123'); + + $entityManager->persist($this->user); + $entityManager->persist($token); + $entityManager->flush(); + } + + public function testConfirm() + { + $client = $this->createClient( + [ + 'sulu_context' => 'website', + 'environment' => 'dev', + ] + ); + + $crawler = $client->request('GET', '/profile/email-confirmation?token=123-123-123'); + $this->assertHttpStatusCode(200, $client->getResponse()); + + $this->assertCount(1, $crawler->filter('.success')); + $this->assertCount(0, $crawler->filter('.fail')); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $this->getEntityManager(); + $entityManager->clear(); + + $this->assertNull($entityManager->getRepository(EmailConfirmationToken::class)->findByToken('123-123-123')); + + $user = $entityManager->find(User::class, $this->user->getId()); + $contact = $user->getContact(); + + $this->assertEquals($user->getEmail(), $contact->getMainEmail()); + } + + public function testConfirmWrongToken() + { + $client = $this->createClient( + [ + 'sulu_context' => 'website', + 'environment' => 'dev', + ] + ); + + $crawler = $client->request('GET', '/profile/email-confirmation?token=312-312-312'); + $this->assertHttpStatusCode(200, $client->getResponse()); + + $this->assertCount(0, $crawler->filter('.success')); + $this->assertCount(1, $crawler->filter('.fail')); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $this->getEntityManager(); + + $this->assertNotNull($entityManager->getRepository(EmailConfirmationToken::class)->findByToken('123-123-123')); + } +} diff --git a/Tests/Unit/Listener/EmailConfirmationListenerTest.php b/Tests/Unit/Listener/EmailConfirmationListenerTest.php new file mode 100644 index 00000000..11453d36 --- /dev/null +++ b/Tests/Unit/Listener/EmailConfirmationListenerTest.php @@ -0,0 +1,168 @@ +mailFactory = $this->prophesize(MailFactoryInterface::class); + $this->entityManager = $this->prophesize(EntityManagerInterface::class); + $this->repository = $this->prophesize(EmailConfirmationTokenRepository::class); + $this->tokenGenerator = $this->prophesize(TokenGeneratorInterface::class); + + $this->listener = new EmailConfirmationListener( + $this->mailFactory->reveal(), + $this->entityManager->reveal(), + $this->repository->reveal(), + $this->tokenGenerator->reveal() + ); + + $this->event = $this->prophesize(CommunityEvent::class); + $this->user = $this->prophesize(User::class); + $this->contact = $this->prophesize(Contact::class); + $this->token = $this->prophesize(EmailConfirmationToken::class); + + $this->event->getUser()->willReturn($this->user->reveal()); + $this->event->getConfigProperty(Argument::any())->willReturnArgument(0); + $this->event->getConfigTypeProperty(Argument::cetera())->willReturn( + [ + Configuration::EMAIL_SUBJECT => '', + Configuration::EMAIL_USER_TEMPLATE => '', + Configuration::EMAIL_ADMIN_TEMPLATE => '' + ] + ); + $this->user->getContact()->willReturn($this->contact->reveal()); + $this->token->getUser()->willReturn($this->user->reveal()); + } + + public function testSendConfirmation() + { + $this->user->getEmail()->willReturn('test@sulu.io'); + $this->contact->getMainEmail()->willReturn('new@sulu.io'); + $this->repository->findByUser($this->user->reveal())->willReturn(null); + $this->tokenGenerator->generateToken()->willReturn('123-123-123'); + + $this->entityManager->persist( + Argument::that( + function (EmailConfirmationToken $token) { + return $token->getToken() === '123-123-123' && $token->getUser() === $this->user->reveal(); + } + ) + ); + $this->entityManager->flush(); + + $this->mailFactory->sendEmails( + Argument::type(Mail::class), + $this->user->reveal(), + ['token' => '123-123-123'] + )->shouldBeCalled(); + + $this->listener->sendConfirmationOnEmailChange($this->event->reveal()); + } + + public function testSendConfirmationExistingToken() + { + $this->user->getEmail()->willReturn('test@sulu.io'); + $this->contact->getMainEmail()->willReturn('new@sulu.io'); + $this->repository->findByUser($this->user->reveal())->willReturn($this->token->reveal()); + $this->tokenGenerator->generateToken()->willReturn('123-123-123'); + + $this->token->setToken('123-123-123')->shouldBeCalled(); + $this->token->getToken()->willReturn('123-123-123'); + + $this->entityManager->persist(Argument::any())->shouldNotBeCalled(); + $this->entityManager->flush()->shouldBeCalled(); + + $this->mailFactory->sendEmails( + Argument::type(Mail::class), + $this->user->reveal(), + ['token' => '123-123-123'] + )->shouldBeCalled(); + + $this->listener->sendConfirmationOnEmailChange($this->event->reveal()); + } + + public function testSendConfirmationNoChange() + { + $this->user->getEmail()->willReturn('test@sulu.io'); + $this->contact->getMainEmail()->willReturn('test@sulu.io'); + + $this->entityManager->persist(Argument::any())->shouldNotBeCalled(); + $this->entityManager->flush()->shouldNotBeCalled(); + + $this->mailFactory->sendEmails(Argument::cetera())->shouldNotBeCalled(); + + $this->listener->sendConfirmationOnEmailChange($this->event->reveal()); + } +}