diff --git a/api/src/Repository/CampCollaborationRepository.php b/api/src/Repository/CampCollaborationRepository.php index 60024a4df4..5840b33265 100644 --- a/api/src/Repository/CampCollaborationRepository.php +++ b/api/src/Repository/CampCollaborationRepository.php @@ -34,6 +34,13 @@ public function findByUserAndIdAndInvited(User $user, string $id): ?CampCollabor return $this->findOneBy(['user' => $user, 'id' => $id, 'status' => CampCollaboration::STATUS_INVITED]); } + /** + * @return CampCollaboration[] + */ + public function findAllByInviteEmailAndInvited(string $inviteEmail): array { + return $this->findBy(['inviteEmail' =>$inviteEmail, 'status' => CampCollaboration::STATUS_INVITED]); + } + /** * @return CampCollaboration[] */ diff --git a/api/src/State/ProfileUpdateProcessor.php b/api/src/State/ProfileUpdateProcessor.php index cc9f188a96..eaf807c6f3 100644 --- a/api/src/State/ProfileUpdateProcessor.php +++ b/api/src/State/ProfileUpdateProcessor.php @@ -5,9 +5,17 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\Profile; +use App\Entity\User; +use App\Repository\CampCollaborationRepository; +use App\Repository\UserRepository; use App\Service\MailService; use App\State\Util\AbstractPersistProcessor; use App\Util\IdGenerator; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\NonUniqueResultException; +use Doctrine\ORM\NoResultException; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; use Symfony\Component\PasswordHasher\PasswordHasherInterface; @@ -19,7 +27,11 @@ class ProfileUpdateProcessor extends AbstractPersistProcessor { public function __construct( ProcessorInterface $decorated, private PasswordHasherFactoryInterface $pwHasherFactory, - private MailService $mailService + private MailService $mailService, + private readonly Security $security, + private readonly CampCollaborationRepository $campCollaborationRepository, + private readonly UserRepository $userRepository, + private readonly EntityManagerInterface $em, ) { parent::__construct($decorated); } @@ -58,9 +70,37 @@ public function onAfter($data, Operation $operation, array $uriVariables = [], a $this->mailService->sendEmailVerificationMail($data->user, $data); $data->untrustedEmailKey = null; } + + $user = $this->getUser(); + $personalInvitationsForNewEmail = $this->campCollaborationRepository->findAllByInviteEmailAndInvited($data->email); + foreach ($personalInvitationsForNewEmail as $invitation) { + // Convert all invitations who specifically invited this email address to + // personal invitations, which the invited user will be able to see and + // accept / reject in the UI, even without receiving the invitation email. + // This is done by setting the user field instead of the inviteEmail field. + $invitation->inviteEmail = null; + $invitation->user = $user; + $this->em->persist($invitation); + } + $this->em->flush(); } private function getResetKeyHasher(): PasswordHasherInterface { return $this->pwHasherFactory->getPasswordHasher('EmailVerification'); } + + /** + * @throws NonUniqueResultException + * @throws NoResultException + */ + private function getUser(): ?User { + $user = $this->security->getUser(); + if (null == $user) { + // This should never happen because it should be caught earlier by our security settings + // on all API operations using this processor. + throw new AccessDeniedHttpException(); + } + + return $this->userRepository->loadUserByIdentifier($user->getUserIdentifier()); + } } diff --git a/api/src/State/UserActivateProcessor.php b/api/src/State/UserActivateProcessor.php index 0ebe0d89c3..54e86b83e3 100644 --- a/api/src/State/UserActivateProcessor.php +++ b/api/src/State/UserActivateProcessor.php @@ -5,7 +5,9 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\User; +use App\Repository\CampCollaborationRepository; use App\State\Util\AbstractPersistProcessor; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpKernel\Exception\HttpException; /** @@ -13,7 +15,9 @@ */ class UserActivateProcessor extends AbstractPersistProcessor { public function __construct( - ProcessorInterface $decorated + ProcessorInterface $decorated, + private readonly CampCollaborationRepository $campCollaborationRepository, + private readonly EntityManagerInterface $em, ) { parent::__construct($decorated); } @@ -32,4 +36,20 @@ public function onBefore($data, Operation $operation, array $uriVariables = [], return $data; } + + public function onAfter($data, Operation $operation, array $uriVariables = [], array $context = []): void { + /** @var User $user */ + $user = $data; + $personalInvitationsForNewEmail = $this->campCollaborationRepository->findAllByInviteEmailAndInvited($user->getProfile()->email); + foreach ($personalInvitationsForNewEmail as $invitation) { + // Convert all invitations who specifically invited this email address to + // personal invitations, which the invited user will be able to see and + // accept / reject in the UI, even without receiving the invitation email. + // This is done by setting the user field instead of the inviteEmail field. + $invitation->inviteEmail = null; + $invitation->user = $user; + $this->em->persist($invitation); + } + $this->em->flush(); + } } diff --git a/api/tests/Api/Profiles/UpdateProfileTest.php b/api/tests/Api/Profiles/UpdateProfileTest.php index 2270e3da14..2fc9781912 100644 --- a/api/tests/Api/Profiles/UpdateProfileTest.php +++ b/api/tests/Api/Profiles/UpdateProfileTest.php @@ -2,9 +2,15 @@ namespace App\Tests\Api\Profiles; +use _PHPStan_5473b6701\Nette\Neon\Exception; +use App\Entity\Camp; +use App\Entity\CampCollaboration; use App\Entity\Profile; use App\Tests\Api\ECampApiTestCase; use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; /** * @internal @@ -91,6 +97,70 @@ public function testPatchProfileDisallowsChangingEmail() { ]); } + public function testPatchProfileCollectsPersonalInvitation() { + + $client = static::createClientWithCredentials(); + // Disable resetting the database between the two requests + $client->disableReboot(); + + $camp = $this->getEntityManager()->find(Camp::class, static::getFixture('campUnrelated')->getId()); + $camp2 = $this->getEntityManager()->find(Camp::class, static::getFixture('campPrototype')->getId()); + + // create an invitation which will be claimed by the user + $invitation1 = new CampCollaboration(); + $invitation1->camp = $camp; + $invitation1->status = CampCollaboration::STATUS_INVITED; + $invitation1->inviteEmail = 'test@example.com'; + $invitation1->inviteKeyHash = '1234123412341234'; + $invitation1->role = CampCollaboration::ROLE_MANAGER; + $this->getEntityManager()->persist($invitation1); + + // create a rejected invitation which will not be claimed by the user + $invitation2 = new CampCollaboration(); + $invitation2->camp = $camp2; + $invitation2->status = CampCollaboration::STATUS_INACTIVE; + $invitation2->inviteEmail = 'test@example.com'; + $invitation2->inviteKeyHash = '2341234123412341'; + $invitation2->role = CampCollaboration::ROLE_MANAGER; + $this->getEntityManager()->persist($invitation2); + + // create an unrelated invitation which will not be claimed by the user + $invitation3 = new CampCollaboration(); + $invitation3->camp = $camp; + $invitation3->status = CampCollaboration::STATUS_INVITED; + $invitation3->inviteEmail = 'someone-else@example.com'; + $invitation3->inviteKeyHash = '3412341234123412'; + $invitation3->role = CampCollaboration::ROLE_MANAGER; + $this->getEntityManager()->persist($invitation3); + + $this->getEntityManager()->flush(); + + /** @var Profile $profile */ + $profile = static::getFixture('profile1manager'); + + // when + $client->request('PATCH', '/profiles/'.$profile->getId(), ['json' => [ + 'nickname' => 'Linux', + ], 'headers' => ['Content-Type' => 'application/merge-patch+json']]); + $this->assertResponseStatusCodeSame(200); + + // then + $client->request('GET', '/personal_invitations'); + + // User has one personal invitation waiting for them + $this->assertJsonContains([ + 'totalItems' => 1, + '_links' => [ + 'items' => [ + ['href' => "/personal_invitations/{$invitation1->getId()}"] + ], + ], + '_embedded' => [ + 'items' => [], + ], + ]); + } + public function testPatchProfileTrimsFirstname() { $profile = static::getFixture('profile1manager'); static::createClientWithCredentials()->request('PATCH', '/profiles/'.$profile->getId(), ['json' => [ diff --git a/api/tests/Api/Users/CreateUserTest.php b/api/tests/Api/Users/CreateUserTest.php index bc22473620..8b172b95c6 100644 --- a/api/tests/Api/Users/CreateUserTest.php +++ b/api/tests/Api/Users/CreateUserTest.php @@ -4,6 +4,8 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Post; +use App\Entity\Camp; +use App\Entity\CampCollaboration; use App\Entity\Profile; use App\Entity\User; use App\Tests\Api\ECampApiTestCase; @@ -77,6 +79,81 @@ public function testLoginAfterRegistrationAndActivation() { $this->assertResponseIsSuccessful(); } + public function testActivationClaimsOpenInvitations() { + // given + $client = static::createBasicClient(); + // Disable resetting the database between the two requests + $client->disableReboot(); + + $camp = $this->getEntityManager()->find(Camp::class, static::getFixture('camp1')->getId()); + $camp2 = $this->getEntityManager()->find(Camp::class, static::getFixture('camp2')->getId()); + + // create an invitation which will be claimed by the user + $invitation1 = new CampCollaboration(); + $invitation1->camp = $camp; + $invitation1->status = CampCollaboration::STATUS_INVITED; + $invitation1->inviteEmail = 'bi-pi@example.com'; + $invitation1->inviteKeyHash = '1234123412341234'; + $invitation1->role = CampCollaboration::ROLE_MANAGER; + $this->getEntityManager()->persist($invitation1); + + // create a rejected invitation which will not be claimed by the user + $invitation2 = new CampCollaboration(); + $invitation2->camp = $camp2; + $invitation2->status = CampCollaboration::STATUS_INACTIVE; + $invitation2->inviteEmail = 'bi-pi@example.com'; + $invitation2->inviteKeyHash = '2341234123412341'; + $invitation2->role = CampCollaboration::ROLE_MANAGER; + $this->getEntityManager()->persist($invitation2); + + // create an unrelated invitation which will not be claimed by the user + $invitation3 = new CampCollaboration(); + $invitation3->camp = $camp; + $invitation3->status = CampCollaboration::STATUS_INVITED; + $invitation3->inviteEmail = 'someone-else@example.com'; + $invitation3->inviteKeyHash = '3412341234123412'; + $invitation3->role = CampCollaboration::ROLE_MANAGER; + $this->getEntityManager()->persist($invitation3); + + $this->getEntityManager()->flush(); + + // register user + $result = $client->request('POST', '/users', ['json' => $this->getExampleWritePayload()]); + $this->assertResponseStatusCodeSame(201); + + $userId = $result->toArray()['id']; + $user = $this->getEntityManager()->getRepository(User::class)->find($userId); + + // when + // activate user + $client->request('PATCH', "/users/{$userId}/activate", ['json' => [ + 'activationKey' => $user->activationKey, + ], 'headers' => ['Content-Type' => 'application/merge-patch+json']]); + $this->assertResponseIsSuccessful(); + + // login + $client->request('POST', '/authentication_token', ['json' => [ + 'identifier' => 'bi-pi@example.com', + 'password' => 'learning-by-doing-101', + ]]); + + // then + $client->request('GET', '/personal_invitations'); + + // User has one personal invitation waiting for them + $this->assertJsonContains([ + 'totalItems' => 1, + '_links' => [ + 'items' => [ + ['href' => "/personal_invitations/{$invitation1->getId()}"] + ], + ], + '_embedded' => [ + 'items' => [], + ], + ]); + } + public function testActivationFailsIfAlreadyActivated() { $client = static::createBasicClient(); // Disable resetting the database between the two requests