diff --git a/compose.yaml b/compose.yaml index b6c3ec36..3d88e078 100644 --- a/compose.yaml +++ b/compose.yaml @@ -31,7 +31,7 @@ services: CADDY_PROTOCOL: ${CADDY_PROTOCOL:-https} HTTP_PORT: ${HTTP_PORT:-80} HTTPS_PORT: ${HTTPS_PORT:-443} - MAILER_DSN: ${MAILER_DSN:-smtp://localhost} + MAILER_DSN: ${MAILER_DSN:-smtp://mail.puc.local:1025} cap_add: - NET_ADMIN depends_on: diff --git a/symfony/composer.json b/symfony/composer.json index 9e1ea4b4..7f0cf746 100755 --- a/symfony/composer.json +++ b/symfony/composer.json @@ -43,6 +43,7 @@ "symfony/uid": "7.1.*", "symfony/validator": "7.1.*", "symfony/yaml": "7.1.*", + "symfonycasts/verify-email-bundle": "^1.17", "tilleuls/forgot-password-bundle": "^1.6", "vich/uploader-bundle": "*" }, diff --git a/symfony/composer.lock b/symfony/composer.lock index 10df47b5..d4f591f5 100755 --- a/symfony/composer.lock +++ b/symfony/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7fdb9628159caa98f583c3d17b19c11d", + "content-hash": "2f8269274477c5894b6fb87ba5ac5d52", "packages": [ { "name": "api-platform/core", @@ -8901,6 +8901,52 @@ ], "time": "2024-09-25T14:20:29+00:00" }, + { + "name": "symfonycasts/verify-email-bundle", + "version": "v1.17.3", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/verify-email-bundle.git", + "reference": "2cb1cd94ca7a65471563a5cb91ddf40e8433844e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/verify-email-bundle/zipball/2cb1cd94ca7a65471563a5cb91ddf40e8433844e", + "reference": "2cb1cd94ca7a65471563a5cb91ddf40e8433844e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1", + "symfony/config": "^5.4 | ^6.0 | ^7.0", + "symfony/dependency-injection": "^5.4 | ^6.0 | ^7.0", + "symfony/deprecation-contracts": "^2.2 | ^3.0", + "symfony/http-kernel": "^5.4 | ^6.0 | ^7.0", + "symfony/routing": "^5.4 | ^6.0 | ^7.0" + }, + "require-dev": { + "doctrine/orm": "^2.7", + "doctrine/persistence": "^2.0", + "symfony/framework-bundle": "^5.4 | ^6.0 | ^7.0", + "symfony/phpunit-bridge": "^5.4 | ^6.0 | ^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "SymfonyCasts\\Bundle\\VerifyEmail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Simple, stylish Email Verification for Symfony", + "support": { + "issues": "https://github.com/SymfonyCasts/verify-email-bundle/issues", + "source": "https://github.com/SymfonyCasts/verify-email-bundle/tree/v1.17.3" + }, + "time": "2024-12-09T18:44:25+00:00" + }, { "name": "theofidry/alice-data-fixtures", "version": "1.8.0", diff --git a/symfony/config/bundles.php b/symfony/config/bundles.php index ee4d21a0..d36ac0b5 100755 --- a/symfony/config/bundles.php +++ b/symfony/config/bundles.php @@ -19,4 +19,5 @@ Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true], Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true], CoopTilleuls\ForgotPasswordBundle\CoopTilleulsForgotPasswordBundle::class => ['all' => true], + SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], ]; diff --git a/symfony/config/packages/security.yaml b/symfony/config/packages/security.yaml index 2ae02a28..8b32ab7a 100755 --- a/symfony/config/packages/security.yaml +++ b/symfony/config/packages/security.yaml @@ -28,7 +28,7 @@ security: check_path: /auth username_path: email password_path: password - success_handler: lexik_jwt_authentication.handler.authentication_success + success_handler: App\Security\Authenticator\AuthenticationSuccessHandler failure_handler: lexik_jwt_authentication.handler.authentication_failure # activate different ways to authenticate @@ -43,6 +43,7 @@ security: - { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/auth, roles: PUBLIC_ACCESS } - { path: ^/api/users, roles: PUBLIC_ACCESS, methods: [POST]} + - { path: ^/api/users/verify_email, roles: PUBLIC_ACCESS, methods: [GET, POST]} - { path: ^/api/actors, roles: PUBLIC_ACCESS, methods: [GET] } - { path: ^/api/projects, roles: PUBLIC_ACCESS, methods: [GET] } - { path: ^/api/actor_expertises, roles: PUBLIC_ACCESS, methods: [GET] } diff --git a/symfony/config/packages/verify_email.yaml b/symfony/config/packages/verify_email.yaml new file mode 100644 index 00000000..5eeb5f89 --- /dev/null +++ b/symfony/config/packages/verify_email.yaml @@ -0,0 +1,2 @@ +symfonycasts_verify_email: + lifetime: 172800 # 2 days diff --git a/symfony/config/services.yaml b/symfony/config/services.yaml index 725c8c02..4da7df75 100755 --- a/symfony/config/services.yaml +++ b/symfony/config/services.yaml @@ -8,6 +8,7 @@ imports: parameters: domain: '%env(DOMAIN)%' domain.url: 'https://%env(DOMAIN)%' + email_verifier.lifetime: 172800 # 2 days services: # default configuration for services in *this* file @@ -25,9 +26,9 @@ services: - '../src/DependencyInjection/' - '../src/Entity/' - '../src/Kernel.php' - - '../src/Services/State/' - '../src/Services/Serializer/' - + - '../src/Services/Service/' + - '../src/Services/State/' # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/symfony/config/services/State/state.yaml b/symfony/config/services/State/state.yaml index fcea29dc..588f50cf 100755 --- a/symfony/config/services/State/state.yaml +++ b/symfony/config/services/State/state.yaml @@ -2,5 +2,6 @@ services: _defaults: autowire: true autoconfigure: true + App\Services\State\: - resource: '../../../src/Services/State/' \ No newline at end of file + resource: '../../../src/Services/State/' diff --git a/symfony/config/services/service/email_verifier.yaml b/symfony/config/services/service/email_verifier.yaml new file mode 100644 index 00000000..4399c461 --- /dev/null +++ b/symfony/config/services/service/email_verifier.yaml @@ -0,0 +1,14 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + App\Services\Service\EmailVerifier\: + resource: '../../../src/Services/Service/EmailVerifier/' + + App\Services\Service\EmailVerifier\EmailVerifierSignature: + arguments: + $lifetime: '%email_verifier.lifetime%' + $userSignatureProperties: ['id', 'email'] + $secret: '%env(APP_SECRET)%' + diff --git a/symfony/config/services/service/service.yaml b/symfony/config/services/service/service.yaml new file mode 100644 index 00000000..3d369cde --- /dev/null +++ b/symfony/config/services/service/service.yaml @@ -0,0 +1,10 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + App\Services\Service\: + resource: '../../../src/Services/Service/' + exclude: + - '../../../src/Services/Service/EmailVerifier' + diff --git a/symfony/fixtures/dev/users.yaml b/symfony/fixtures/dev/users.yaml new file mode 100755 index 00000000..ed0355d8 --- /dev/null +++ b/symfony/fixtures/dev/users.yaml @@ -0,0 +1,8 @@ +App\Entity\User\User: + user_yv: + firstName: yoh + lastName: val + email: y_valentin@cartong.org + plainPassword: y_valentin@cartong.org + roles: [ 'ROLE_ADMIN' ] + isValidated: true diff --git a/symfony/migrations/Version20241220153020.php b/symfony/migrations/Version20241220153020.php new file mode 100644 index 00000000..97c7f5c3 --- /dev/null +++ b/symfony/migrations/Version20241220153020.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE "user" ADD has_seen_requested_roles BOOLEAN NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE "user" DROP has_seen_requested_roles'); + } +} diff --git a/symfony/src/Entity/User/User.php b/symfony/src/Entity/User/User.php index 20fa5cad..a831e7bc 100755 --- a/symfony/src/Entity/User/User.php +++ b/symfony/src/Entity/User/User.php @@ -18,12 +18,18 @@ use App\Model\Enums\UserRoles; use App\Repository\User\UserRepository; use App\Security\Voter\UserVoter; +use App\Services\Service\EmailVerifier\Dto\EmailVerifierSendDto; +use App\Services\Service\EmailVerifier\Dto\EmailVerifierVerifyDto; +use App\Services\Service\EmailVerifier\Exception\SignatureParamsException; +use App\Services\State\Processor\User\UserVerifyEmailProcessor; use App\Services\State\Provider\CurrentUserProvider; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException; +use Symfony\Component\Security\Core\Signature\Exception\InvalidSignatureException; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Serializer\Attribute\Groups; @@ -33,6 +39,27 @@ #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] #[UniqueEntity('email')] +#[ApiResource( + operations: [ + new Post( + uriTemplate: '/users/verify_email/send', + processor: UserVerifyEmailProcessor::class, + input: EmailVerifierSendDto::class, + status: 204 + ), + new Post( + uriTemplate: '/users/verify_email/verify', + input: EmailVerifierVerifyDto::class, + processor: UserVerifyEmailProcessor::class, + status: 204, + exceptionToStatus: [ + ExpiredSignatureException::class => 410, + InvalidSignatureException::class => 400, + SignatureParamsException::class => 400, + ] + ), + ] +)] #[ApiResource( operations: [ new Get( @@ -109,17 +136,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[SerializedName('password')] private ?string $plainPassword = null; - /** - * @var Collection - */ - #[ORM\OneToMany(targetEntity: Actor::class, mappedBy: 'createdBy', orphanRemoval: true)] - private Collection $actorsCreated; - #[ORM\Column(nullable: true)] #[Assert\Choice(choices: self::ACCEPTED_ROLES, multiple: true)] #[Groups([self::GROUP_READ, self::GROUP_GETME, self::GROUP_WRITE])] private ?array $requestedRoles = null; + #[ORM\Column(type: Types::BOOLEAN)] + #[Groups([self::GROUP_GETME, self::GROUP_WRITE])] + private bool $hasSeenRequestedRoles = false; + #[ORM\Column(length: 255, nullable: true)] #[Groups([self::GROUP_READ, self::GROUP_GETME, self::GROUP_WRITE])] private ?string $organisation = null; @@ -144,6 +169,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Groups([self::GROUP_READ, self::GROUP_GETME, self::GROUP_WRITE])] private ?string $description = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Actor::class, mappedBy: 'createdBy', orphanRemoval: true)] + private Collection $actorsCreated; + /** * @var Collection */ @@ -299,7 +330,7 @@ public function setLastName(string $lastName): static } #[Groups([Project::GET_FULL, Project::GET_PARTIAL, Actor::ACTOR_READ_ITEM, Resource::GET_FULL])] - public function getFullName(): ?string + public function getFullName(): string { return $this->firstName.' '.$this->lastName; } @@ -316,6 +347,18 @@ public function setRequestedRoles(?array $requestedRoles): static return $this; } + public function hasSeenRequestedRoles(): ?bool + { + return $this->hasSeenRequestedRoles; + } + + public function setHasSeenRequestedRoles(bool $hasSeenRequestedRoles): static + { + $this->hasSeenRequestedRoles = $hasSeenRequestedRoles; + + return $this; + } + public function getOrganisation(): ?string { return $this->organisation; @@ -352,12 +395,12 @@ public function setPhone(?string $phone): static return $this; } - public function getsignUpMessage(): ?string + public function getSignUpMessage(): ?string { return $this->signUpMessage; } - public function setsignUpMessage(?string $signUpMessage): static + public function setSignUpMessage(?string $signUpMessage): static { $this->signUpMessage = $signUpMessage; diff --git a/symfony/src/Entity/User/UserLike.php b/symfony/src/Entity/User/UserLike.php index 82e7b974..772317a7 100755 --- a/symfony/src/Entity/User/UserLike.php +++ b/symfony/src/Entity/User/UserLike.php @@ -7,7 +7,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use App\Repository\User\UserLikeRepository; -use App\Services\State\Provider\UserLikeProvider; +use App\Services\State\Provider\User\UserLikeProvider; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; diff --git a/symfony/src/Event/EventListener/UserListener.php b/symfony/src/Event/EventListener/UserListener.php index 3bbe6173..55554fb2 100755 --- a/symfony/src/Event/EventListener/UserListener.php +++ b/symfony/src/Event/EventListener/UserListener.php @@ -4,19 +4,25 @@ use App\Entity\User\User; use App\Security\PasswordHasher; +use App\Services\Mailer\User\UserEmailVerifierMailer; use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; use Doctrine\ORM\Events; use Doctrine\Persistence\Event\LifecycleEventArgs; #[AsEntityListener(event: Events::prePersist, method: 'hashPassword', entity: User::class)] +#[AsEntityListener(event: Events::postPersist, method: 'postPersist', entity: User::class)] #[AsEntityListener(event: Events::preUpdate, method: 'hashPassword', entity: User::class)] class UserListener { - private PasswordHasher $passwordHasher; + public function __construct( + private readonly PasswordHasher $passwordHasher, + private readonly UserEmailVerifierMailer $userEmailVerifierMailer, + ) { + } - public function __construct(PasswordHasher $passwordHasher) + public function postPersist(User $user, LifecycleEventArgs $event): void { - $this->passwordHasher = $passwordHasher; + $this->userEmailVerifierMailer->send($user); } public function hashPassword(User $user, LifecycleEventArgs $event): void diff --git a/symfony/src/Repository/User/UserRepository.php b/symfony/src/Repository/User/UserRepository.php index 182fe912..93e53c90 100755 --- a/symfony/src/Repository/User/UserRepository.php +++ b/symfony/src/Repository/User/UserRepository.php @@ -29,6 +29,11 @@ public function upgradePassword(PasswordAuthenticatedUserInterface $user, string } $user->setPassword($newHashedPassword); + $this->save($user); + } + + public function save(User $user): void + { $this->getEntityManager()->persist($user); $this->getEntityManager()->flush(); } diff --git a/symfony/src/Security/Authenticator/AuthenticationSuccessHandler.php b/symfony/src/Security/Authenticator/AuthenticationSuccessHandler.php new file mode 100644 index 00000000..836f29f2 --- /dev/null +++ b/symfony/src/Security/Authenticator/AuthenticationSuccessHandler.php @@ -0,0 +1,30 @@ +getUser(); + if (!$user->getIsValidated()) { + throw new InvalidUserException('User is not validated'); + } + + return $this->authenticationSuccessHandlerDecorated->onAuthenticationSuccess($request, $token); + } +} diff --git a/symfony/src/Security/Authenticator/Exception/InvalidUserException.php b/symfony/src/Security/Authenticator/Exception/InvalidUserException.php new file mode 100755 index 00000000..74b8e8b2 --- /dev/null +++ b/symfony/src/Security/Authenticator/Exception/InvalidUserException.php @@ -0,0 +1,13 @@ +verifyEmail->getSignature(); + + $email = (new TemplatedEmail()) + ->to(new Address($user->getEmail(), $user->getFullName())) + ->subject($this->translator->trans('mail.verify_email.subject')) + ->htmlTemplate('mail/user/verify_email.html.twig') + ->context( + [ + 'verify_email_url' => sprintf('%s/%s&%s', $this->domainUrl, $signature->generateQueryParams($user), self::VERIFY_EMAIL_DIALOG), + 'user' => $user, + ] + ); + + $this->mailer->send($email); + } +} diff --git a/symfony/src/Services/Mailer/User/UserResetPasswordMailer.php b/symfony/src/Services/Mailer/User/UserResetPasswordMailer.php index eaebc5af..62a7c219 100644 --- a/symfony/src/Services/Mailer/User/UserResetPasswordMailer.php +++ b/symfony/src/Services/Mailer/User/UserResetPasswordMailer.php @@ -5,20 +5,11 @@ use App\Entity\User\User; use CoopTilleuls\ForgotPasswordBundle\Entity\AbstractPasswordToken; use Symfony\Bridge\Twig\Mime\TemplatedEmail; -use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Address; -use Symfony\Contracts\Translation\TranslatorInterface; -final class UserResetPasswordMailer +final class UserResetPasswordMailer extends AbstractUserMailer { - private const string RESET_PASSWORD = '/?dialog=reset-password&token='; - - public function __construct( - private readonly MailerInterface $mailer, - private readonly TranslatorInterface $translator, - private readonly string $domainUrl, - ) { - } + private const string RESET_PASSWORD_PATH = '/?dialog=reset-password&token='; public function sent(AbstractPasswordToken $passwordToken): void { @@ -31,7 +22,7 @@ public function sent(AbstractPasswordToken $passwordToken): void ->htmlTemplate('mail/user/reset_password.html.twig') ->context( [ - 'reset_password_url' => sprintf('%s%s%s', $this->domainUrl, self::RESET_PASSWORD, $passwordToken->getToken()), + 'reset_password_url' => sprintf('%s%s%s', $this->domainUrl, self::RESET_PASSWORD_PATH, $passwordToken->getToken()), 'user' => $user, ] ); diff --git a/symfony/src/Services/Service/EmailVerifier/Dto/EmailVerifierSendDto.php b/symfony/src/Services/Service/EmailVerifier/Dto/EmailVerifierSendDto.php new file mode 100644 index 00000000..cd82ac3c --- /dev/null +++ b/symfony/src/Services/Service/EmailVerifier/Dto/EmailVerifierSendDto.php @@ -0,0 +1,12 @@ +signature; + } + + /** + * @throws SignatureParamsException + */ + public function validSignatureAndUser(EmailVerifierVerifyDto $emailVerifierDto): void + { + $this->signature->acceptEmailVerifierDto($emailVerifierDto); + + $this->signature->acceptSignatureHash($emailVerifierDto->email, $emailVerifierDto->expiresAt, $emailVerifierDto->token); + + $user = $this->userRepository->findOneBy(['email' => $emailVerifierDto->email]); + + if (!$user) { + throw new NotFoundHttpException(); + } + + $this->signature->verifySignatureHash($user, $emailVerifierDto->expiresAt, $emailVerifierDto->token); + + $user->setIsValidated(true); + + $this->userRepository->save($user); + } +} diff --git a/symfony/src/Services/Service/EmailVerifier/EmailVerifierSignature.php b/symfony/src/Services/Service/EmailVerifier/EmailVerifierSignature.php new file mode 100644 index 00000000..6cbf75d6 --- /dev/null +++ b/symfony/src/Services/Service/EmailVerifier/EmailVerifierSignature.php @@ -0,0 +1,94 @@ +signature = new SignatureHasher($propertyAccessor, $userSignatureProperties, $secret); + } + + /** + * @throws SignatureParamsException + */ + public function generateQueryParams(User $user): string + { + return $this->uriSigner->sign($this->getHttpQueryParams($this->getQueryParams($user))); + } + + /** + * @throws SignatureParamsException + */ + private function getQueryParams(User $user): array + { + if (null === $user->getEmail()) { + throw new SignatureParamsException(self::QUERY_PARAM_USER_EMAIL); + } + + $expiresAt = $this->getExpiresAt(); + + return [ + self::QUERY_PARAM_TOKEN => $this->signature->computeSignatureHash($user, $expiresAt), + self::QUERY_PARAM_EXPIRES_AT => $expiresAt, + self::QUERY_PARAM_USER_EMAIL => $user->getEmail(), + ]; + } + + private function getHttpQueryParams(array $queryParams): string + { + return '?'.http_build_query($queryParams, '', '&'); + } + + private function getExpiresAt(): int + { + $now = time(); + + return $now + $this->lifetime; + } + + public function acceptSignatureHash(string $email, int $expiresAt, string $token): void + { + $this->signature->acceptSignatureHash($email, $expiresAt, $token); + } + + public function verifySignatureHash(User $user, int $expires, string $hash): void + { + $this->signature->verifySignatureHash($user, $expires, $hash); + } + + /** + * @throws SignatureParamsException + */ + public function acceptEmailVerifierDto(EmailVerifierVerifyDto $emailVerifierDto): void + { + $queryParams = [ + self::QUERY_PARAM_TOKEN => $emailVerifierDto->token, + self::QUERY_PARAM_EXPIRES_AT => $emailVerifierDto->expiresAt, + self::QUERY_PARAM_USER_EMAIL => $emailVerifierDto->email, + self::QUERY_PARAM_HASH => $emailVerifierDto->_hash, + ]; + + if (!$this->uriSigner->check($this->getHttpQueryParams($queryParams))) { + throw new SignatureParamsException(self::QUERY_PARAM_HASH); + } + } +} diff --git a/symfony/src/Services/Service/EmailVerifier/Exception/SignatureParamsException.php b/symfony/src/Services/Service/EmailVerifier/Exception/SignatureParamsException.php new file mode 100644 index 00000000..68a0d2e9 --- /dev/null +++ b/symfony/src/Services/Service/EmailVerifier/Exception/SignatureParamsException.php @@ -0,0 +1,11 @@ +emailVerifierService->validSignatureAndUser($data); + + return; + } + + if ($data instanceof EmailVerifierSendDto) { + $this->send($data); + } + } + + /** + * @throws SignatureParamsException + * @throws TransportExceptionInterface + * @throws \JsonException + */ + private function send(EmailVerifierSendDto $data): void + { + $user = $this->userRepository->findOneBy(['email' => $data->email]); + if (!$user instanceof User || $user->getIsValidated()) { + return; + } + + $this->userEmailVerifierMailer->send($user); + } +} diff --git a/symfony/src/Services/State/Provider/UserLikeProvider.php b/symfony/src/Services/State/Provider/User/UserLikeProvider.php similarity index 94% rename from symfony/src/Services/State/Provider/UserLikeProvider.php rename to symfony/src/Services/State/Provider/User/UserLikeProvider.php index 4b6f017f..e08d7e25 100755 --- a/symfony/src/Services/State/Provider/UserLikeProvider.php +++ b/symfony/src/Services/State/Provider/User/UserLikeProvider.php @@ -1,10 +1,11 @@
Cordialement, + + verify_email: + subject: Plateforme Urbaine - Vérification de l'adresse e-mail + content: |- + Chér⋅e {user_fullname},

+ + Merci de vous être inscrit⋅e sur la Plateforme Urbaine !

+ + Veuillez cliquer ici pour vérifier votre adresse e-mail et activer votre compte, ou copiez et collez le lien suivant dans votre navigateur :
+ {link}

+ + Le lien expirera dans 2 jours, alors assurez-vous de vérifier votre adresse e-mail avant cette date.

+ + Ceci est un message automatique, merci de ne pas y répondre.

+ Si une autre personne a saisi votre adresse ou si vous ne voulez pas terminer votre inscription sur la Plateforme Urbaine, vous pouvez ignorer ce message. +

+ + Cordialement, diff --git a/vue/src/assets/translations/fr/auth.json b/vue/src/assets/translations/fr/auth.json index df42797b..27589f0c 100644 --- a/vue/src/assets/translations/fr/auth.json +++ b/vue/src/assets/translations/fr/auth.json @@ -12,6 +12,7 @@ "title": "Mon compte" }, "auth": { + "becomeMember": { "error": "Cet email est déjà utilisé.", "form": { @@ -37,6 +38,11 @@ "title": "Devenir membre" }, "becomeMemberThanks": { + "subtitle": "Un email de vérification vous a été envoyé.", + "info": "Veuillez vérifier votre boite mail pour activer votre compte et poursuivre votre adhésion.", + "title": "Merci" + }, + "becomeMemberAskRoles": { "form": { "actionsRequest": { "addActors": "Ajouter des acteurs", @@ -67,6 +73,21 @@ }, "title": "Pourquoi adhérer ?" }, + "emailVerifier": { + "resend": "Renvoyer le lien de vérification d'email", + "title": "Vérification de l'email", + "subtitle": { + "success": "Votre email a bien été vérifié.", + "wrong": "Le lien de vérification n'est pas valide.", + "failed": "Une erreur est survenue lors de la vérification de votre email.", + "expired": "Le lien de vérification a expiré.", + "sent": "Un email de vérification vous a été envoyé." + }, + "content": { + "expired": "Vous pouvez demander un nouveau lien de vérification via l'espace membre en vous connectant à votre compte." + }, + "close": "Fermer" + }, "editForm": { "newMember": "Nouvel utilisateur inscrit le", "newRequest": "Nouvelle demande", @@ -108,6 +129,10 @@ }, "signIn": { "error": "L'email ou le mot de passe sont incorrects.", + "invalidAccount": { + "message": "Le compte n'est pas valide, vérifiez votre compte via l'email de vérification.", + "resendActivationEmail": "Renvoyer un email de vérification" + }, "form": { "email": "Email", "forgotPassword": "Mot de passe oublié ?", diff --git a/vue/src/assets/translations/fr/common.json b/vue/src/assets/translations/fr/common.json index bc46d647..8b44d3f5 100644 --- a/vue/src/assets/translations/fr/common.json +++ b/vue/src/assets/translations/fr/common.json @@ -14,6 +14,10 @@ "resources": "Ressources", "services": "Services" }, + "dialog": { + "close": "Fermer", + "submit": "Envoyer" + }, "header": { "title": "Plateforme", "title2": "Urbaine", @@ -56,7 +60,7 @@ "create": "Créer", "delete": "Supprimer", "errorMessages": { - "required": "Ce champs est requis", + "required": "Ce champs est requis", "minlength": "Ce champs doit avoir au moins {min} caractères", "maxlength": "Ce champs est limité à {max} caractères", "email": "Veuillez renseigner une adresse email valide", @@ -183,4 +187,4 @@ "resetFilters": "Réinitialiser les filtres", "filtersTitle": "Affinez votre recherche" } -} \ No newline at end of file +} diff --git a/vue/src/components/banners/SectionBanner.vue b/vue/src/components/banners/SectionBanner.vue index 626df68c..30aded0f 100644 --- a/vue/src/components/banners/SectionBanner.vue +++ b/vue/src/components/banners/SectionBanner.vue @@ -26,37 +26,37 @@ defineProps<{ diff --git a/vue/src/components/global/Dialog.vue b/vue/src/components/global/Dialog.vue index 0f3738a7..9bb80407 100644 --- a/vue/src/components/global/Dialog.vue +++ b/vue/src/components/global/Dialog.vue @@ -71,6 +71,7 @@ const closeDialog = () => router.replace({ query: { dialog: undefined } }) flex-flow: column nowrap; align-items: center; justify-content: center; + text-align: justify; .Link--withoutUnderline { width: fit-content; diff --git a/vue/src/components/global/DialogController.vue b/vue/src/components/global/DialogController.vue index 6c3d71d3..e72fee0e 100644 --- a/vue/src/components/global/DialogController.vue +++ b/vue/src/components/global/DialogController.vue @@ -6,6 +6,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue' import { VScaleTransition } from 'vuetify/components' import { useApplicationStore } from '@/stores/applicationStore' +import AuthAskEmailVerifier from '@/views/auth/AuthEmailVerifierSend.vue' import AuthSignIn from '@/views/auth/AuthSignIn.vue' import AuthBecomeMember from '@/views/auth/AuthBecomeMember.vue' import AuthBecomeMemberWhy from '@/views/auth/AuthBecomeMemberWhy.vue' @@ -15,6 +16,8 @@ import AuthForgotPasswordOk from '@/views/auth/AuthForgotPasswordOk.vue' import AuthResetPassword from '@/views/auth/AuthResetPassword.vue' import { DialogKey } from '@/models/enums/app/DialogKey' import AuthResetPasswordOk from '@/views/auth/AuthResetPasswordOk.vue' +import AuthEmailVerifier from '@/views/auth/AuthEmailVerifier.vue' +import AuthBecomeMemberRoles from '@/views/auth/AuthBecomeMemberRoles.vue' const DEFAULT_TRANSITION = VScaleTransition @@ -40,6 +43,8 @@ const dialogComponent = computed(() => { return AuthBecomeMemberWhy case DialogKey.AUTH_BECOME_MEMBER_THANKS: return AuthBecomeMemberThanks + case DialogKey.AUTH_BECOME_MEMBER_ROLES: + return AuthBecomeMemberRoles case DialogKey.AUTH_FORGOT_PASSWORD: return AuthForgotPassword case DialogKey.AUTH_FORGOT_PASSWORD_OK: @@ -48,6 +53,10 @@ const dialogComponent = computed(() => { return AuthResetPassword case DialogKey.AUTH_RESET_PASSWORD_OK: return AuthResetPasswordOk + case DialogKey.AUTH_EMAIL_VERIFIER: + return AuthEmailVerifier + case DialogKey.AUTH_EMAIL_VERIFIER_ASK: + return AuthAskEmailVerifier default: return null } diff --git a/vue/src/components/global/WrongPoint.vue b/vue/src/components/global/WrongPoint.vue new file mode 100644 index 00000000..a7110fc5 --- /dev/null +++ b/vue/src/components/global/WrongPoint.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/vue/src/models/enums/app/DialogKey.ts b/vue/src/models/enums/app/DialogKey.ts index 0c6a8958..6a1dc057 100644 --- a/vue/src/models/enums/app/DialogKey.ts +++ b/vue/src/models/enums/app/DialogKey.ts @@ -3,8 +3,11 @@ export enum DialogKey { AUTH_BECOME_MEMBER = 'become-member', AUTH_BECOME_MEMBER_WHY = 'become-member-why', AUTH_BECOME_MEMBER_THANKS = 'become-member-thanks', + AUTH_BECOME_MEMBER_ROLES = 'become-member-roles', AUTH_FORGOT_PASSWORD = 'forgot-password', AUTH_FORGOT_PASSWORD_OK = 'forgot-password-ok', AUTH_RESET_PASSWORD = 'reset-password', - AUTH_RESET_PASSWORD_OK = 'reset-password-ok' + AUTH_RESET_PASSWORD_OK = 'reset-password-ok', + AUTH_EMAIL_VERIFIER = 'verify-email', + AUTH_EMAIL_VERIFIER_ASK = 'email-verifier-ask' } diff --git a/vue/src/models/interfaces/auth/AuthenticationsValues.ts b/vue/src/models/interfaces/auth/AuthenticationsValues.ts index 01236581..a9cd4246 100644 --- a/vue/src/models/interfaces/auth/AuthenticationsValues.ts +++ b/vue/src/models/interfaces/auth/AuthenticationsValues.ts @@ -9,3 +9,10 @@ export interface SignUpValues { email: string plainPassword: string } + +export interface EmailVerifierValues { + token: string + _hash: string + email: string + expiresAt: string +} diff --git a/vue/src/models/interfaces/auth/User.ts b/vue/src/models/interfaces/auth/User.ts index 2f3c4145..c4fd4b35 100644 --- a/vue/src/models/interfaces/auth/User.ts +++ b/vue/src/models/interfaces/auth/User.ts @@ -14,6 +14,7 @@ export interface User extends Validateable { email: string roles: UserRoles[] requestedRoles: UserRoles[] + hasSeenRequestedRoles: boolean } export interface UserSubmission extends Omit { diff --git a/vue/src/router/index.ts b/vue/src/router/index.ts index 307f1b10..3431155b 100644 --- a/vue/src/router/index.ts +++ b/vue/src/router/index.ts @@ -162,9 +162,9 @@ const router = createRouter({ router.beforeEach((to, from, next) => { const applicationStore = useApplicationStore() if ( + to.query.dialog !== undefined && typeof to.query.dialog === 'string' && - Object.values(DialogKey).includes(to.query.dialog as any) && - to.query.dialog != undefined + Object.values(DialogKey).includes(to.query.dialog as unknown as DialogKey) ) { applicationStore.activeDialog = to.query.dialog as DialogKey } else { diff --git a/vue/src/services/api/ApiPlatformService.ts b/vue/src/services/api/ApiPlatformService.ts index cc56da44..932cc2e7 100644 --- a/vue/src/services/api/ApiPlatformService.ts +++ b/vue/src/services/api/ApiPlatformService.ts @@ -1,10 +1,10 @@ export const nestedObjectsToIri = (payload: any) => { - for (const key in payload) { - if (Array.isArray(payload[key]) && payload[key][0]?.["@id"]) { - payload[key] = (payload[key]).map((x: any) => x["@id"]); - } else if (typeof payload[key] === "object" && payload[key] && payload[key]["@id"]) { - payload[key] = payload[key]["@id"] - } + for (const key in payload) { + if (Array.isArray(payload[key]) && payload[key][0]?.['@id']) { + payload[key] = payload[key].map((x: any) => x['@id']) + } else if (typeof payload[key] === 'object' && payload[key] && payload[key]['@id']) { + payload[key] = payload[key]['@id'] } - return payload + } + return payload } diff --git a/vue/src/services/userAndAuth/AuthenticationService.ts b/vue/src/services/userAndAuth/AuthenticationService.ts index e96732d2..c8b3b9f7 100644 --- a/vue/src/services/userAndAuth/AuthenticationService.ts +++ b/vue/src/services/userAndAuth/AuthenticationService.ts @@ -1,4 +1,7 @@ -import type { SignInValues } from '@/models/interfaces/auth/AuthenticationsValues' +import type { + EmailVerifierValues, + SignInValues +} from '@/models/interfaces/auth/AuthenticationsValues' import type { User } from '@/models/interfaces/auth/User' import { apiClient } from '@/plugins/axios/api' import type { AxiosResponse } from 'axios' @@ -23,4 +26,12 @@ export class AuthenticationService { static async resetPassword(token: string, password: string): Promise { return apiClient.post(`/api/forgot_password/${token}`, { password: password }) } + + static async verifyEmail(emailVerifierValues: EmailVerifierValues): Promise { + return apiClient.post('/api/users/verify_email/verify', emailVerifierValues) + } + + static async resendEmailVerifier(email: string): Promise { + return apiClient.post(`/api/users/verify_email/send`, { email: email }) + } } diff --git a/vue/src/services/userAndAuth/forms/UserValidator.ts b/vue/src/services/userAndAuth/forms/UserValidator.ts index c5a4647c..297743ac 100644 --- a/vue/src/services/userAndAuth/forms/UserValidator.ts +++ b/vue/src/services/userAndAuth/forms/UserValidator.ts @@ -3,7 +3,9 @@ import { i18n } from '@/plugins/i18n' export class UserValidator { public static get emailSchema() { - return z.string().email({ message: i18n.t('forms.errorMessages.email') }) + return z + .string({ message: i18n.t('forms.errorMessages.required') }) + .email({ message: i18n.t('forms.errorMessages.email') }) } public static get passwordSchema() { diff --git a/vue/src/stores/userStore.ts b/vue/src/stores/userStore.ts index dbb99223..c8818f8a 100644 --- a/vue/src/stores/userStore.ts +++ b/vue/src/stores/userStore.ts @@ -1,6 +1,10 @@ import { DialogKey } from '@/models/enums/app/DialogKey' import { StoresList } from '@/models/enums/app/StoresList' -import type { SignInValues, SignUpValues } from '@/models/interfaces/auth/AuthenticationsValues' +import type { + EmailVerifierValues, + SignInValues, + SignUpValues +} from '@/models/interfaces/auth/AuthenticationsValues' import type { User, UserSubmission } from '@/models/interfaces/auth/User' import { AuthenticationService } from '@/services/userAndAuth/AuthenticationService' import JwtCookie from '@/services/userAndAuth/JWTCookie' @@ -13,12 +17,14 @@ import FileUploader from '@/services/files/FileUploader' import type { MediaObject } from '@/models/interfaces/MediaObject' import { UserService } from '@/services/userAndAuth/UserService' import { useApplicationStore } from './applicationStore' +import { AxiosError } from 'axios' export const useUserStore = defineStore(StoresList.USER, () => { const router = useRouter() const route = useRoute() const currentUser = ref(null) const errorWhileSignInOrSignUp = ref(false) + const invalidAccount = ref(false) const userIsLogged = computed(() => currentUser.value !== null) const userIsAdmin = () => userIsLogged.value && currentUser.value?.roles.includes(UserRoles.ADMIN) const userIsEditor = () => @@ -32,34 +38,44 @@ export const useUserStore = defineStore(StoresList.USER, () => { const signIn = async (values: SignInValues, hideDialog = true) => { try { await AuthenticationService.signIn(values) - setCurrentUser() + await setCurrentUser() errorWhileSignInOrSignUp.value = false + console.log(currentUser.value?.hasSeenRequestedRoles === false) + if (currentUser.value?.hasSeenRequestedRoles === false) { + await router.replace({ + query: { ...route.query, dialog: DialogKey.AUTH_BECOME_MEMBER_ROLES } + }) + } if (hideDialog) { - router.replace({ query: { ...route.query, dialog: undefined } }) + await router.replace({ query: { ...route.query, dialog: undefined } }) } } catch (err) { Sentry.captureException(err) + if (err instanceof AxiosError && err.response?.status === 401) { + invalidAccount.value = true + return + } errorWhileSignInOrSignUp.value = true } } const setCurrentUser = async () => { - currentUser.value = (await AuthenticationService.getAuthenticatedUser()).data + await getAuthenticatedUser() const appStore = useApplicationStore() appStore.getLikesList() } + const getAuthenticatedUser = async () => { + currentUser.value = (await AuthenticationService.getAuthenticatedUser()).data + console.log(currentUser.value) + } + const signUp = async (values: SignUpValues) => { try { await UserService.createUser(values) - signIn( - { - email: values.email, - password: values.plainPassword - }, - false - ) - router.replace({ query: { ...route.query, dialog: DialogKey.AUTH_BECOME_MEMBER_THANKS } }) + await router.replace({ + query: { ...route.query, dialog: DialogKey.AUTH_BECOME_MEMBER_THANKS } + }) } catch (err) { errorWhileSignInOrSignUp.value = true Sentry.captureException(err) @@ -72,6 +88,13 @@ export const useUserStore = defineStore(StoresList.USER, () => { router.push({ name: 'home' }) } + const verifyEmail = async (emailVerifierValues: EmailVerifierValues) => { + await AuthenticationService.verifyEmail(emailVerifierValues) + if (userIsLogged.value) { + await getAuthenticatedUser() + } + } + const checkAuthenticated = async () => { const jwtCookieIsValid = JwtCookie.isValid() if (jwtCookieIsValid) { @@ -102,9 +125,11 @@ export const useUserStore = defineStore(StoresList.USER, () => { userHasRole, currentUser, errorWhileSignInOrSignUp, + invalidAccount, signIn, signUp, signOut, + verifyEmail, checkAuthenticated, patchUser } diff --git a/vue/src/views/auth/AuthBecomeMemberRoles.vue b/vue/src/views/auth/AuthBecomeMemberRoles.vue new file mode 100644 index 00000000..8aac9178 --- /dev/null +++ b/vue/src/views/auth/AuthBecomeMemberRoles.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/vue/src/views/auth/AuthBecomeMemberThanks.vue b/vue/src/views/auth/AuthBecomeMemberThanks.vue index 81b1d763..e38a7d0b 100644 --- a/vue/src/views/auth/AuthBecomeMemberThanks.vue +++ b/vue/src/views/auth/AuthBecomeMemberThanks.vue @@ -7,69 +7,22 @@ :label="$t('auth.becomeMemberThanks.subtitle')" :highlighted="true" /> - {{ $t('auth.becomeMemberThanks.form.info') }} -
- - - - - {{ $t('auth.becomeMemberThanks.form.actionsRequest.label') }} - - - - - - {{ - $t('auth.becomeMemberThanks.form.submit') - }} - + {{ $t('auth.becomeMemberThanks.info') }} + + {{ $t('dialog.close') }} + + diff --git a/vue/src/views/member/MemberView.vue b/vue/src/views/member/MemberView.vue index cfe218a4..a21122f7 100644 --- a/vue/src/views/member/MemberView.vue +++ b/vue/src/views/member/MemberView.vue @@ -63,7 +63,6 @@ :label="$t('auth.becomeMemberThanks.form.telephone')" @submit="form.phone.handleChange" /> -
- +
- - + - + + :item-value="(item: ResourceType) => item" + :label="$t('resources.type')" + />
- +
- {{ arePassedEventsShown ? - $t('resources.hidePassedEvents') : $t('resources.showPassedEvents') }} + @click="arePassedEventsShown = !arePassedEventsShown" + >{{ + arePassedEventsShown + ? $t('resources.hidePassedEvents') + : $t('resources.showPassedEvents') + }} - {{ $t('resources.name') - }} - {{ $t('resources.date') - }} - {{ $t('resources.updatedAt') - }} + {{ + $t('resources.name') + }} + {{ + $t('resources.date') + }} + {{ + $t('resources.updatedAt') + }} - {{ $t('resources.add') - }} + v-if="userStore.userIsAdmin() || userStore.userHasRole(UserRoles.EDITOR_RESSOURCES)" + >{{ $t('resources.add') }}