Add email verification, organizer approval, and forgot password features
- Add isVerified, emailVerificationToken, emailVerifiedAt fields to User entity
- Send verification email on registration with token link
- Add /verification-email/{token} route to confirm email
- Send notification emails to organizer and staff on organizer email verification
- Add isApproved and offer fields to User entity for organizer approval workflow
- Auto-verify and auto-approve SSO Keycloak users with offer='custom'
- Add resetCode and resetCodeExpiresAt fields to User entity
- Create ForgotPasswordController with 2-step flow (email -> code + new password)
- Block forgot password for SSO users (no local password)
- Add "Mot de passe oublie" link on login page
- Create email templates: verification, reset_code, organizer_pending, organizer_request
- Add migrations for all new fields
- Add tests: ForgotPasswordControllerTest (9 tests), update RegistrationControllerTest,
update UserTest with verification, approval, offer, and reset code fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
35
migrations/Version20260319110454.php
Normal file
35
migrations/Version20260319110454.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260319110454 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE "user" ADD is_verified BOOLEAN NOT NULL DEFAULT FALSE');
|
||||
$this->addSql('ALTER TABLE "user" ADD email_verification_token VARCHAR(64) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ADD email_verified_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE "user" DROP is_verified');
|
||||
$this->addSql('ALTER TABLE "user" DROP email_verification_token');
|
||||
$this->addSql('ALTER TABLE "user" DROP email_verified_at');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260319110647.php
Normal file
35
migrations/Version20260319110647.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260319110647 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE "user" ADD is_approved BOOLEAN NOT NULL DEFAULT FALSE');
|
||||
$this->addSql('ALTER TABLE "user" ADD offer VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ALTER is_verified DROP DEFAULT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE "user" DROP is_approved');
|
||||
$this->addSql('ALTER TABLE "user" DROP offer');
|
||||
$this->addSql('ALTER TABLE "user" ALTER is_verified SET DEFAULT false');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260319111127.php
Normal file
35
migrations/Version20260319111127.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260319111127 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE "user" ADD reset_code VARCHAR(6) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ADD reset_code_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE "user" ALTER is_approved DROP DEFAULT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE "user" DROP reset_code');
|
||||
$this->addSql('ALTER TABLE "user" DROP reset_code_expires_at');
|
||||
$this->addSql('ALTER TABLE "user" ALTER is_approved SET DEFAULT false');
|
||||
}
|
||||
}
|
||||
126
src/Controller/ForgotPasswordController.php
Normal file
126
src/Controller/ForgotPasswordController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class ForgotPasswordController extends AbstractController
|
||||
{
|
||||
private const CODE_EXPIRATION_MINUTES = 15;
|
||||
|
||||
#[Route('/mot-de-passe-oublie', name: 'app_forgot_password', methods: ['GET', 'POST'])]
|
||||
public function index(
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
MailerService $mailerService,
|
||||
UserPasswordHasherInterface $passwordHasher,
|
||||
): Response {
|
||||
$step = $request->request->getString('step', 'email');
|
||||
$email = $request->request->getString('email', $request->getSession()->get('reset_email', ''));
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
if ('email' === $step) {
|
||||
return $this->handleEmailStep($request, $em, $mailerService, $email);
|
||||
}
|
||||
|
||||
if ('code' === $step) {
|
||||
return $this->handleCodeStep($request, $em, $passwordHasher, $email);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('security/forgot_password.html.twig', [
|
||||
'step' => 'email',
|
||||
'email' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleEmailStep(Request $request, EntityManagerInterface $em, MailerService $mailerService, string $email): Response
|
||||
{
|
||||
$email = trim($email);
|
||||
|
||||
if ('' === $email) {
|
||||
$this->addFlash('error', 'Veuillez saisir votre adresse email.');
|
||||
|
||||
return $this->render('security/forgot_password.html.twig', [
|
||||
'step' => 'email',
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
|
||||
if ($user && null === $user->getKeycloakId()) {
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
|
||||
$user->setResetCode($code);
|
||||
$user->setResetCodeExpiresAt(new \DateTimeImmutable(sprintf('+%d minutes', self::CODE_EXPIRATION_MINUTES)));
|
||||
$em->flush();
|
||||
|
||||
$html = $this->renderView('email/reset_code.html.twig', [
|
||||
'firstName' => $user->getFirstName(),
|
||||
'code' => $code,
|
||||
'expirationMinutes' => self::CODE_EXPIRATION_MINUTES,
|
||||
]);
|
||||
|
||||
$mailerService->sendEmail(
|
||||
to: $email,
|
||||
subject: 'Votre code de reinitialisation - E-Ticket',
|
||||
content: $html,
|
||||
withUnsubscribe: false,
|
||||
);
|
||||
}
|
||||
|
||||
$request->getSession()->set('reset_email', $email);
|
||||
$this->addFlash('success', 'Si un compte existe avec cet email, un code vous a ete envoye.');
|
||||
|
||||
return $this->render('security/forgot_password.html.twig', [
|
||||
'step' => 'code',
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleCodeStep(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher, string $email): Response
|
||||
{
|
||||
$code = trim($request->request->getString('code'));
|
||||
$password = $request->request->getString('password');
|
||||
|
||||
if ('' === $code || '' === $password) {
|
||||
$this->addFlash('error', 'Veuillez remplir tous les champs.');
|
||||
|
||||
return $this->render('security/forgot_password.html.twig', [
|
||||
'step' => 'code',
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy([
|
||||
'email' => $email,
|
||||
'resetCode' => $code,
|
||||
]);
|
||||
|
||||
if (!$user || !$user->getResetCodeExpiresAt() || $user->getResetCodeExpiresAt() < new \DateTimeImmutable()) {
|
||||
$this->addFlash('error', 'Code invalide ou expire.');
|
||||
|
||||
return $this->render('security/forgot_password.html.twig', [
|
||||
'step' => 'code',
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
|
||||
$user->setPassword($passwordHasher->hashPassword($user, $password));
|
||||
$user->setResetCode(null);
|
||||
$user->setResetCodeExpiresAt(null);
|
||||
$em->flush();
|
||||
|
||||
$request->getSession()->remove('reset_email');
|
||||
$this->addFlash('success', 'Mot de passe modifie avec succes. Vous pouvez vous connecter.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
class RegistrationController extends AbstractController
|
||||
@@ -19,6 +21,7 @@ class RegistrationController extends AbstractController
|
||||
UserPasswordHasherInterface $passwordHasher,
|
||||
EntityManagerInterface $em,
|
||||
ValidatorInterface $validator,
|
||||
MailerService $mailerService,
|
||||
): Response {
|
||||
if ($this->getUser()) {
|
||||
return $this->redirectToRoute('app_account');
|
||||
@@ -44,11 +47,31 @@ class RegistrationController extends AbstractController
|
||||
$user->setPhone(trim($request->request->getString('phone')));
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$user->setEmailVerificationToken($token);
|
||||
|
||||
$errors = $validator->validate($user);
|
||||
if (0 === count($errors)) {
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Compte créé avec succès ! Connectez-vous.');
|
||||
|
||||
$verificationUrl = $this->generateUrl('app_verify_email', [
|
||||
'token' => $token,
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$html = $this->renderView('email/verification.html.twig', [
|
||||
'firstName' => $user->getFirstName(),
|
||||
'verificationUrl' => $verificationUrl,
|
||||
]);
|
||||
|
||||
$mailerService->sendEmail(
|
||||
to: $user->getEmail(),
|
||||
subject: 'Verifiez votre adresse email - E-Ticket',
|
||||
content: $html,
|
||||
withUnsubscribe: false,
|
||||
);
|
||||
|
||||
$this->addFlash('success', 'Compte cree ! Un email de verification vous a ete envoye.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
@@ -65,4 +88,53 @@ class RegistrationController extends AbstractController
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/verification-email/{token}', name: 'app_verify_email')]
|
||||
public function verifyEmail(string $token, EntityManagerInterface $em, MailerService $mailerService): Response
|
||||
{
|
||||
$user = $em->getRepository(User::class)->findOneBy(['emailVerificationToken' => $token]);
|
||||
|
||||
if (!$user) {
|
||||
$this->addFlash('error', 'Lien de verification invalide ou expire.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
|
||||
$user->setIsVerified(true);
|
||||
$user->setEmailVerifiedAt(new \DateTimeImmutable());
|
||||
$user->setEmailVerificationToken(null);
|
||||
$em->flush();
|
||||
|
||||
if (\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) {
|
||||
$mailerService->sendEmail(
|
||||
to: $user->getEmail(),
|
||||
subject: 'Votre compte organisateur est en cours d\'examen - E-Ticket',
|
||||
content: $this->renderView('email/organizer_pending.html.twig', [
|
||||
'firstName' => $user->getFirstName(),
|
||||
]),
|
||||
withUnsubscribe: false,
|
||||
);
|
||||
|
||||
$mailerService->sendEmail(
|
||||
to: 'contact@e-cosplay.fr',
|
||||
subject: sprintf('Nouvelle demande organisateur : %s %s', $user->getFirstName(), $user->getLastName()),
|
||||
content: $this->renderView('email/organizer_request.html.twig', [
|
||||
'firstName' => $user->getFirstName(),
|
||||
'lastName' => $user->getLastName(),
|
||||
'email' => $user->getEmail(),
|
||||
'companyName' => $user->getCompanyName(),
|
||||
'siret' => $user->getSiret(),
|
||||
'address' => $user->getAddress(),
|
||||
'postalCode' => $user->getPostalCode(),
|
||||
'city' => $user->getCity(),
|
||||
'phone' => $user->getPhone(),
|
||||
]),
|
||||
withUnsubscribe: false,
|
||||
);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Votre adresse email a ete verifiee. Vous pouvez vous connecter.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 6, nullable: true)]
|
||||
private ?string $resetCode = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $resetCodeExpiresAt = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $isVerified = false;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $isApproved = false;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $offer = null;
|
||||
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $emailVerificationToken = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $emailVerifiedAt = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
@@ -215,6 +236,90 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResetCode(): ?string
|
||||
{
|
||||
return $this->resetCode;
|
||||
}
|
||||
|
||||
public function setResetCode(?string $resetCode): static
|
||||
{
|
||||
$this->resetCode = $resetCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResetCodeExpiresAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->resetCodeExpiresAt;
|
||||
}
|
||||
|
||||
public function setResetCodeExpiresAt(?\DateTimeImmutable $resetCodeExpiresAt): static
|
||||
{
|
||||
$this->resetCodeExpiresAt = $resetCodeExpiresAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->isApproved;
|
||||
}
|
||||
|
||||
public function setIsApproved(bool $isApproved): static
|
||||
{
|
||||
$this->isApproved = $isApproved;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOffer(): ?string
|
||||
{
|
||||
return $this->offer;
|
||||
}
|
||||
|
||||
public function setOffer(?string $offer): static
|
||||
{
|
||||
$this->offer = $offer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isVerified(): bool
|
||||
{
|
||||
return $this->isVerified;
|
||||
}
|
||||
|
||||
public function setIsVerified(bool $isVerified): static
|
||||
{
|
||||
$this->isVerified = $isVerified;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmailVerificationToken(): ?string
|
||||
{
|
||||
return $this->emailVerificationToken;
|
||||
}
|
||||
|
||||
public function setEmailVerificationToken(?string $emailVerificationToken): static
|
||||
{
|
||||
$this->emailVerificationToken = $emailVerificationToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmailVerifiedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->emailVerifiedAt;
|
||||
}
|
||||
|
||||
public function setEmailVerifiedAt(?\DateTimeImmutable $emailVerifiedAt): static
|
||||
{
|
||||
$this->emailVerifiedAt = $emailVerifiedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getKeycloakId(): ?string
|
||||
{
|
||||
return $this->keycloakId;
|
||||
|
||||
@@ -64,6 +64,10 @@ class KeycloakAuthenticator extends OAuth2Authenticator
|
||||
if ($existingUser) {
|
||||
$existingUser->setKeycloakId($keycloakId);
|
||||
$existingUser->setRoles($roles);
|
||||
$existingUser->setIsVerified(true);
|
||||
$existingUser->setIsApproved(true);
|
||||
$existingUser->setOffer('custom');
|
||||
$existingUser->setEmailVerifiedAt($existingUser->getEmailVerifiedAt() ?? new \DateTimeImmutable());
|
||||
$this->em->flush();
|
||||
|
||||
return $existingUser;
|
||||
@@ -76,6 +80,10 @@ class KeycloakAuthenticator extends OAuth2Authenticator
|
||||
$newUser->setLastName($data['family_name'] ?? '');
|
||||
$newUser->setPassword('');
|
||||
$newUser->setRoles($roles);
|
||||
$newUser->setIsVerified(true);
|
||||
$newUser->setIsApproved(true);
|
||||
$newUser->setOffer('custom');
|
||||
$newUser->setEmailVerifiedAt(new \DateTimeImmutable());
|
||||
|
||||
$this->em->persist($newUser);
|
||||
$this->em->flush();
|
||||
|
||||
10
templates/email/organizer_pending.html.twig
Normal file
10
templates/email/organizer_pending.html.twig
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends 'email/base.html.twig' %}
|
||||
|
||||
{% block title %}Votre compte organisateur est en cours d'examen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Bonjour {{ firstName }} !</h2>
|
||||
<p>Votre adresse email a ete verifiee avec succes.</p>
|
||||
<p>Votre demande de compte organisateur est maintenant <strong>en cours d'examen</strong> par l'equipe E-Ticket. Nous allons analyser votre dossier et vous informerons de notre decision dans les plus brefs delais.</p>
|
||||
<p style="font-size:14px;color:#a1a1aa;margin-top:24px;">Si vous avez des questions, n'hesitez pas a nous contacter a <a href="mailto:contact@e-cosplay.fr" style="color:#7c3aed;text-decoration:none;">contact@e-cosplay.fr</a></p>
|
||||
{% endblock %}
|
||||
46
templates/email/organizer_request.html.twig
Normal file
46
templates/email/organizer_request.html.twig
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends 'email/base.html.twig' %}
|
||||
|
||||
{% block title %}Nouvelle demande de compte organisateur{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Nouvelle demande organisateur</h2>
|
||||
<p>Un nouveau compte organisateur vient de verifier son email et attend votre approbation.</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:24px 0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding:8px 12px;font-weight:700;font-size:12px;color:#a1a1aa;text-align:left;text-transform:uppercase;letter-spacing:0.05em;border-bottom:2px solid #e4e4e7;width:140px;">Champ</th>
|
||||
<th style="padding:8px 12px;font-weight:700;font-size:12px;color:#a1a1aa;text-align:left;text-transform:uppercase;letter-spacing:0.05em;border-bottom:2px solid #e4e4e7;">Valeur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Nom</td>
|
||||
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;">{{ lastName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Prenom</td>
|
||||
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;">{{ firstName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Email</td>
|
||||
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;"><a href="mailto:{{ email }}" style="color:#7c3aed;text-decoration:none;">{{ email }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Raison sociale</td>
|
||||
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;">{{ companyName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">SIRET</td>
|
||||
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;">{{ siret }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Adresse</td>
|
||||
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;">{{ address }}, {{ postalCode }} {{ city }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:700;font-size:14px;color:#18181b;border-bottom:1px solid #e4e4e7;">Telephone</td>
|
||||
<td style="padding:8px 12px;font-size:14px;color:#3f3f46;border-bottom:1px solid #e4e4e7;">{{ phone }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
12
templates/email/reset_code.html.twig
Normal file
12
templates/email/reset_code.html.twig
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends 'email/base.html.twig' %}
|
||||
|
||||
{% block title %}Code de reinitialisation{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Bonjour {{ firstName }} !</h2>
|
||||
<p>Vous avez demande la reinitialisation de votre mot de passe. Voici votre code de verification :</p>
|
||||
<div style="text-align:center;margin:32px 0;">
|
||||
<span style="display:inline-block;padding:16px 32px;background:#f4f4f5;border-radius:8px;font-size:32px;font-weight:700;letter-spacing:0.3em;color:#18181b;">{{ code }}</span>
|
||||
</div>
|
||||
<p style="font-size:14px;color:#a1a1aa;">Ce code est valable {{ expirationMinutes }} minutes. Si vous n'avez pas fait cette demande, ignorez cet email.</p>
|
||||
{% endblock %}
|
||||
12
templates/email/verification.html.twig
Normal file
12
templates/email/verification.html.twig
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends 'email/base.html.twig' %}
|
||||
|
||||
{% block title %}Verifiez votre adresse email{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Bienvenue {{ firstName }} !</h2>
|
||||
<p>Merci de vous etre inscrit sur E-Ticket. Pour activer votre compte, veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous.</p>
|
||||
<p style="text-align:center;margin:32px 0;">
|
||||
<a href="{{ verificationUrl }}" class="btn">Verifier mon email</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#a1a1aa;">Si vous n'avez pas cree de compte sur E-Ticket, vous pouvez ignorer cet email.</p>
|
||||
{% endblock %}
|
||||
81
templates/security/forgot_password.html.twig
Normal file
81
templates/security/forgot_password.html.twig
Normal file
@@ -0,0 +1,81 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Mot de passe oublie - E-Ticket{% endblock %}
|
||||
{% block description %}Reinitialisation de votre mot de passe E-Ticket{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div style="max-width:28rem;margin:0 auto;padding:3rem 1rem;">
|
||||
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Mot de passe oublie</h1>
|
||||
<p class="font-bold text-gray-600 italic" style="margin-bottom:2rem;">
|
||||
{% if step == 'email' %}
|
||||
Saisissez votre email pour recevoir un code.
|
||||
{% else %}
|
||||
Entrez le code recu par email et votre nouveau mot de passe.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% for message in app.flashes('success') %}
|
||||
<div style="border:4px solid #111827;padding:1rem 1.5rem;margin-bottom:2rem;background:#d1fae5;box-shadow:4px 4px 0 rgba(0,0,0,1);">
|
||||
<p class="font-black text-sm">{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% for message in app.flashes('error') %}
|
||||
<div style="border:4px solid #111827;padding:1rem 1.5rem;margin-bottom:2rem;background:#fee2e2;box-shadow:4px 4px 0 rgba(0,0,0,1);">
|
||||
<p class="font-black text-sm">{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if step == 'email' %}
|
||||
<form method="post" action="{{ path('app_forgot_password') }}" style="display:flex;flex-direction:column;gap:1.5rem;">
|
||||
<input type="hidden" name="step" value="email">
|
||||
<div>
|
||||
<label for="forgot_email" class="text-xs font-black uppercase tracking-widest" style="display:block;margin-bottom:0.5rem;">Email</label>
|
||||
<input type="email" id="forgot_email" name="email" required autofocus
|
||||
value="{{ email }}"
|
||||
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;"
|
||||
class="focus:border-indigo-600"
|
||||
placeholder="jean.dupont@exemple.fr">
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit"
|
||||
style="width:100%;padding:0.75rem 2rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);cursor:pointer;"
|
||||
class="bg-yellow-400 font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
|
||||
Envoyer le code
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ path('app_forgot_password') }}" style="display:flex;flex-direction:column;gap:1.5rem;">
|
||||
<input type="hidden" name="step" value="code">
|
||||
<input type="hidden" name="email" value="{{ email }}">
|
||||
<div>
|
||||
<label for="forgot_code" class="text-xs font-black uppercase tracking-widest" style="display:block;margin-bottom:0.5rem;">Code de verification</label>
|
||||
<input type="text" id="forgot_code" name="code" required autofocus
|
||||
maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
|
||||
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;text-align:center;font-size:1.5rem;letter-spacing:0.3em;"
|
||||
class="focus:border-indigo-600"
|
||||
placeholder="000000">
|
||||
</div>
|
||||
<div>
|
||||
<label for="forgot_password" class="text-xs font-black uppercase tracking-widest" style="display:block;margin-bottom:0.5rem;">Nouveau mot de passe</label>
|
||||
<input type="password" id="forgot_password" name="password" required minlength="8"
|
||||
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;"
|
||||
class="focus:border-indigo-600"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit"
|
||||
style="width:100%;padding:0.75rem 2rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);cursor:pointer;"
|
||||
class="bg-yellow-400 font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
|
||||
Reinitialiser le mot de passe
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top:2rem;text-align:center;">
|
||||
<p class="text-sm font-bold text-gray-600"><a href="{{ path('app_login') }}" class="text-indigo-600 hover:underline font-black">Retour a la connexion</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -38,6 +38,10 @@
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<div style="text-align:right;">
|
||||
<a href="{{ path('app_forgot_password') }}" class="text-xs font-bold text-indigo-600 hover:underline">Mot de passe oublie ?</a>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||
|
||||
<div>
|
||||
|
||||
176
tests/Controller/ForgotPasswordControllerTest.php
Normal file
176
tests/Controller/ForgotPasswordControllerTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
class ForgotPasswordControllerTest extends WebTestCase
|
||||
{
|
||||
public function testPageReturnsSuccess(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/mot-de-passe-oublie');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testEmailStepWithEmptyEmail(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('POST', '/mot-de-passe-oublie', [
|
||||
'step' => 'email',
|
||||
'email' => '',
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testEmailStepSendsCode(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = new User();
|
||||
$user->setEmail('test-forgot-'.uniqid().'@example.com');
|
||||
$user->setFirstName('Test');
|
||||
$user->setLastName('User');
|
||||
$user->setPassword('$2y$13$hashed');
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
$mailer = $this->createMock(MailerService::class);
|
||||
$mailer->expects(self::once())->method('sendEmail');
|
||||
static::getContainer()->set(MailerService::class, $mailer);
|
||||
|
||||
$client->request('POST', '/mot-de-passe-oublie', [
|
||||
'step' => 'email',
|
||||
'email' => $user->getEmail(),
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$em->refresh($user);
|
||||
self::assertNotNull($user->getResetCode());
|
||||
self::assertNotNull($user->getResetCodeExpiresAt());
|
||||
}
|
||||
|
||||
public function testEmailStepIgnoresSsoUser(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = new User();
|
||||
$user->setEmail('test-sso-'.uniqid().'@example.com');
|
||||
$user->setFirstName('SSO');
|
||||
$user->setLastName('User');
|
||||
$user->setPassword('');
|
||||
$user->setKeycloakId('kc-'.uniqid());
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
$client->request('POST', '/mot-de-passe-oublie', [
|
||||
'step' => 'email',
|
||||
'email' => $user->getEmail(),
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$em->refresh($user);
|
||||
self::assertNull($user->getResetCode());
|
||||
}
|
||||
|
||||
public function testEmailStepWithUnknownEmailDoesNotReveal(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('POST', '/mot-de-passe-oublie', [
|
||||
'step' => 'email',
|
||||
'email' => 'unknown-'.uniqid().'@example.com',
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testCodeStepWithEmptyFields(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('POST', '/mot-de-passe-oublie', [
|
||||
'step' => 'code',
|
||||
'email' => 'test@example.com',
|
||||
'code' => '',
|
||||
'password' => '',
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testCodeStepWithInvalidCode(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('POST', '/mot-de-passe-oublie', [
|
||||
'step' => 'code',
|
||||
'email' => 'test@example.com',
|
||||
'code' => '000000',
|
||||
'password' => 'NewPassword123!',
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testCodeStepWithExpiredCode(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = new User();
|
||||
$user->setEmail('test-expired-'.uniqid().'@example.com');
|
||||
$user->setFirstName('Test');
|
||||
$user->setLastName('User');
|
||||
$user->setPassword('$2y$13$hashed');
|
||||
$user->setResetCode('123456');
|
||||
$user->setResetCodeExpiresAt(new \DateTimeImmutable('-1 hour'));
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
$client->request('POST', '/mot-de-passe-oublie', [
|
||||
'step' => 'code',
|
||||
'email' => $user->getEmail(),
|
||||
'code' => '123456',
|
||||
'password' => 'NewPassword123!',
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testCodeStepWithValidCodeResetsPassword(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = new User();
|
||||
$user->setEmail('test-reset-'.uniqid().'@example.com');
|
||||
$user->setFirstName('Test');
|
||||
$user->setLastName('User');
|
||||
$user->setPassword('$2y$13$hashed');
|
||||
$user->setResetCode('654321');
|
||||
$user->setResetCodeExpiresAt(new \DateTimeImmutable('+15 minutes'));
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
$client->request('POST', '/mot-de-passe-oublie', [
|
||||
'step' => 'code',
|
||||
'email' => $user->getEmail(),
|
||||
'code' => '654321',
|
||||
'password' => 'NewPassword123!',
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects('/connexion');
|
||||
|
||||
$em->refresh($user);
|
||||
self::assertNull($user->getResetCode());
|
||||
self::assertNull($user->getResetCodeExpiresAt());
|
||||
self::assertNotSame('$2y$13$hashed', $user->getPassword());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
@@ -35,9 +36,14 @@ class RegistrationControllerTest extends WebTestCase
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testRegistrationWithValidData(): void
|
||||
public function testRegistrationWithValidDataSendsVerificationEmail(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$mailer = $this->createMock(MailerService::class);
|
||||
$mailer->expects(self::once())->method('sendEmail');
|
||||
static::getContainer()->set(MailerService::class, $mailer);
|
||||
|
||||
$client->request('POST', '/inscription', [
|
||||
'first_name' => 'Jean',
|
||||
'last_name' => 'Dupont',
|
||||
@@ -51,6 +57,11 @@ class RegistrationControllerTest extends WebTestCase
|
||||
public function testRegistrationAsOrganizer(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$mailer = $this->createMock(MailerService::class);
|
||||
$mailer->expects(self::once())->method('sendEmail');
|
||||
static::getContainer()->set(MailerService::class, $mailer);
|
||||
|
||||
$client->request('POST', '/inscription', [
|
||||
'type' => 'organizer',
|
||||
'first_name' => 'Marie',
|
||||
@@ -73,6 +84,10 @@ class RegistrationControllerTest extends WebTestCase
|
||||
$email = 'duplicate-'.uniqid().'@example.com';
|
||||
|
||||
$client = static::createClient();
|
||||
|
||||
$mailer = $this->createMock(MailerService::class);
|
||||
static::getContainer()->set(MailerService::class, $mailer);
|
||||
|
||||
$client->request('POST', '/inscription', [
|
||||
'first_name' => 'Jean',
|
||||
'last_name' => 'Dupont',
|
||||
@@ -89,4 +104,68 @@ class RegistrationControllerTest extends WebTestCase
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testVerifyEmailWithValidToken(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = new User();
|
||||
$user->setEmail('test-verify-'.uniqid().'@example.com');
|
||||
$user->setFirstName('Test');
|
||||
$user->setLastName('User');
|
||||
$user->setPassword('$2y$13$hashed');
|
||||
$user->setEmailVerificationToken('valid-token-'.uniqid());
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
$token = $user->getEmailVerificationToken();
|
||||
$client->request('GET', '/verification-email/'.$token);
|
||||
|
||||
self::assertResponseRedirects('/connexion');
|
||||
|
||||
$em->refresh($user);
|
||||
self::assertTrue($user->isVerified());
|
||||
self::assertNotNull($user->getEmailVerifiedAt());
|
||||
self::assertNull($user->getEmailVerificationToken());
|
||||
}
|
||||
|
||||
public function testVerifyEmailOrganizerSendsNotificationEmails(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$mailer = $this->createMock(MailerService::class);
|
||||
$mailer->expects(self::exactly(2))->method('sendEmail');
|
||||
static::getContainer()->set(MailerService::class, $mailer);
|
||||
|
||||
$user = new User();
|
||||
$user->setEmail('test-orga-verify-'.uniqid().'@example.com');
|
||||
$user->setFirstName('Marie');
|
||||
$user->setLastName('Martin');
|
||||
$user->setPassword('$2y$13$hashed');
|
||||
$user->setRoles(['ROLE_ORGANIZER']);
|
||||
$user->setCompanyName('Mon Asso');
|
||||
$user->setSiret('12345678901234');
|
||||
$user->setAddress('12 rue de la Paix');
|
||||
$user->setPostalCode('75001');
|
||||
$user->setCity('Paris');
|
||||
$user->setPhone('0612345678');
|
||||
$user->setEmailVerificationToken('orga-token-'.uniqid());
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
$token = $user->getEmailVerificationToken();
|
||||
$client->request('GET', '/verification-email/'.$token);
|
||||
|
||||
self::assertResponseRedirects('/connexion');
|
||||
}
|
||||
|
||||
public function testVerifyEmailWithInvalidToken(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/verification-email/invalid-token');
|
||||
|
||||
self::assertResponseRedirects('/connexion');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,54 @@ class UserTest extends TestCase
|
||||
self::assertNull($user->getPhone());
|
||||
}
|
||||
|
||||
public function testResetCodeFields(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
self::assertNull($user->getResetCode());
|
||||
self::assertNull($user->getResetCodeExpiresAt());
|
||||
|
||||
$expiry = new \DateTimeImmutable('+15 minutes');
|
||||
$result = $user->setResetCode('123456')->setResetCodeExpiresAt($expiry);
|
||||
|
||||
self::assertSame($user, $result);
|
||||
self::assertSame('123456', $user->getResetCode());
|
||||
self::assertSame($expiry, $user->getResetCodeExpiresAt());
|
||||
}
|
||||
|
||||
public function testApprovalAndOfferFields(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
self::assertFalse($user->isApproved());
|
||||
self::assertNull($user->getOffer());
|
||||
|
||||
$result = $user->setIsApproved(true)->setOffer('custom');
|
||||
|
||||
self::assertSame($user, $result);
|
||||
self::assertTrue($user->isApproved());
|
||||
self::assertSame('custom', $user->getOffer());
|
||||
}
|
||||
|
||||
public function testEmailVerificationFields(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
self::assertFalse($user->isVerified());
|
||||
self::assertNull($user->getEmailVerificationToken());
|
||||
self::assertNull($user->getEmailVerifiedAt());
|
||||
|
||||
$now = new \DateTimeImmutable();
|
||||
$result = $user->setIsVerified(true)
|
||||
->setEmailVerificationToken('abc123')
|
||||
->setEmailVerifiedAt($now);
|
||||
|
||||
self::assertSame($user, $result);
|
||||
self::assertTrue($user->isVerified());
|
||||
self::assertSame('abc123', $user->getEmailVerificationToken());
|
||||
self::assertSame($now, $user->getEmailVerifiedAt());
|
||||
}
|
||||
|
||||
public function testEraseCredentialsDoesNotThrow(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
Reference in New Issue
Block a user