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:
Serreau Jovann
2026-03-19 12:13:32 +01:00
parent 4577e06a19
commit 52e6e2c14c
16 changed files with 886 additions and 2 deletions

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View File

@@ -3,12 +3,14 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\User; use App\Entity\User;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
class RegistrationController extends AbstractController class RegistrationController extends AbstractController
@@ -19,6 +21,7 @@ class RegistrationController extends AbstractController
UserPasswordHasherInterface $passwordHasher, UserPasswordHasherInterface $passwordHasher,
EntityManagerInterface $em, EntityManagerInterface $em,
ValidatorInterface $validator, ValidatorInterface $validator,
MailerService $mailerService,
): Response { ): Response {
if ($this->getUser()) { if ($this->getUser()) {
return $this->redirectToRoute('app_account'); return $this->redirectToRoute('app_account');
@@ -44,11 +47,31 @@ class RegistrationController extends AbstractController
$user->setPhone(trim($request->request->getString('phone'))); $user->setPhone(trim($request->request->getString('phone')));
} }
$token = bin2hex(random_bytes(32));
$user->setEmailVerificationToken($token);
$errors = $validator->validate($user); $errors = $validator->validate($user);
if (0 === count($errors)) { if (0 === count($errors)) {
$em->persist($user); $em->persist($user);
$em->flush(); $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'); 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');
}
} }

View File

@@ -55,6 +55,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 20, nullable: true)] #[ORM\Column(length: 20, nullable: true)]
private ?string $phone = null; 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] #[ORM\Column]
private \DateTimeImmutable $createdAt; private \DateTimeImmutable $createdAt;
@@ -215,6 +236,90 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; 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 public function getKeycloakId(): ?string
{ {
return $this->keycloakId; return $this->keycloakId;

View File

@@ -64,6 +64,10 @@ class KeycloakAuthenticator extends OAuth2Authenticator
if ($existingUser) { if ($existingUser) {
$existingUser->setKeycloakId($keycloakId); $existingUser->setKeycloakId($keycloakId);
$existingUser->setRoles($roles); $existingUser->setRoles($roles);
$existingUser->setIsVerified(true);
$existingUser->setIsApproved(true);
$existingUser->setOffer('custom');
$existingUser->setEmailVerifiedAt($existingUser->getEmailVerifiedAt() ?? new \DateTimeImmutable());
$this->em->flush(); $this->em->flush();
return $existingUser; return $existingUser;
@@ -76,6 +80,10 @@ class KeycloakAuthenticator extends OAuth2Authenticator
$newUser->setLastName($data['family_name'] ?? ''); $newUser->setLastName($data['family_name'] ?? '');
$newUser->setPassword(''); $newUser->setPassword('');
$newUser->setRoles($roles); $newUser->setRoles($roles);
$newUser->setIsVerified(true);
$newUser->setIsApproved(true);
$newUser->setOffer('custom');
$newUser->setEmailVerifiedAt(new \DateTimeImmutable());
$this->em->persist($newUser); $this->em->persist($newUser);
$this->em->flush(); $this->em->flush();

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -38,6 +38,10 @@
placeholder="••••••••"> placeholder="••••••••">
</div> </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') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<div> <div>

View 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());
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Entity\User; use App\Entity\User;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -35,9 +36,14 @@ class RegistrationControllerTest extends WebTestCase
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
} }
public function testRegistrationWithValidData(): void public function testRegistrationWithValidDataSendsVerificationEmail(): void
{ {
$client = static::createClient(); $client = static::createClient();
$mailer = $this->createMock(MailerService::class);
$mailer->expects(self::once())->method('sendEmail');
static::getContainer()->set(MailerService::class, $mailer);
$client->request('POST', '/inscription', [ $client->request('POST', '/inscription', [
'first_name' => 'Jean', 'first_name' => 'Jean',
'last_name' => 'Dupont', 'last_name' => 'Dupont',
@@ -51,6 +57,11 @@ class RegistrationControllerTest extends WebTestCase
public function testRegistrationAsOrganizer(): void public function testRegistrationAsOrganizer(): void
{ {
$client = static::createClient(); $client = static::createClient();
$mailer = $this->createMock(MailerService::class);
$mailer->expects(self::once())->method('sendEmail');
static::getContainer()->set(MailerService::class, $mailer);
$client->request('POST', '/inscription', [ $client->request('POST', '/inscription', [
'type' => 'organizer', 'type' => 'organizer',
'first_name' => 'Marie', 'first_name' => 'Marie',
@@ -73,6 +84,10 @@ class RegistrationControllerTest extends WebTestCase
$email = 'duplicate-'.uniqid().'@example.com'; $email = 'duplicate-'.uniqid().'@example.com';
$client = static::createClient(); $client = static::createClient();
$mailer = $this->createMock(MailerService::class);
static::getContainer()->set(MailerService::class, $mailer);
$client->request('POST', '/inscription', [ $client->request('POST', '/inscription', [
'first_name' => 'Jean', 'first_name' => 'Jean',
'last_name' => 'Dupont', 'last_name' => 'Dupont',
@@ -89,4 +104,68 @@ class RegistrationControllerTest extends WebTestCase
self::assertResponseIsSuccessful(); 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');
}
} }

View File

@@ -88,6 +88,54 @@ class UserTest extends TestCase
self::assertNull($user->getPhone()); 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 public function testEraseCredentialsDoesNotThrow(): void
{ {
$user = new User(); $user = new User();