Skip to content

Commit

Permalink
Claim open invitations when assigning their email to an account
Browse files Browse the repository at this point in the history
  • Loading branch information
carlobeltrame committed Apr 13, 2024
1 parent f79cbfb commit f7cd85f
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 2 deletions.
7 changes: 7 additions & 0 deletions api/src/Repository/CampCollaborationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
*/
Expand Down
42 changes: 41 additions & 1 deletion api/src/State/ProfileUpdateProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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());
}
}
22 changes: 21 additions & 1 deletion api/src/State/UserActivateProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
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;

/**
* @template-extends AbstractPersistProcessor<User>
*/
class UserActivateProcessor extends AbstractPersistProcessor {
public function __construct(
ProcessorInterface $decorated
ProcessorInterface $decorated,
private readonly CampCollaborationRepository $campCollaborationRepository,
private readonly EntityManagerInterface $em,
) {
parent::__construct($decorated);
}
Expand All @@ -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();
}
}
70 changes: 70 additions & 0 deletions api/tests/Api/Profiles/UpdateProfileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = '[email protected]';
$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 = '[email protected]';
$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 = '[email protected]';
$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' => [
Expand Down
77 changes: 77 additions & 0 deletions api/tests/Api/Users/CreateUserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = '[email protected]';
$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 = '[email protected]';
$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 = '[email protected]';
$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' => '[email protected]',
'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
Expand Down

0 comments on commit f7cd85f

Please sign in to comment.