feat(admin): Ajoute gestion des administrateurs avec création et suppression.

Ajoute la gestion complète des administrateurs : création, suppression,
logs d'audit, notifications mail (création/suppression) et désinscription.
```
This commit is contained in:
Serreau Jovann
2026-01-15 19:44:51 +01:00
parent 2e157e1f83
commit 98937f9164
21 changed files with 746 additions and 60 deletions

View File

@@ -0,0 +1,36 @@
<?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 Version20260115183138 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('CREATE TABLE audit_log (id SERIAL NOT NULL, account_id INT DEFAULT NULL, action_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, type VARCHAR(255) NOT NULL, message VARCHAR(255) NOT NULL, path VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_F6E1C0F59B6B5FBA ON audit_log (account_id)');
$this->addSql('COMMENT ON COLUMN audit_log.action_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE audit_log ADD CONSTRAINT FK_F6E1C0F59B6B5FBA FOREIGN KEY (account_id) REFERENCES "account" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
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 audit_log DROP CONSTRAINT FK_F6E1C0F59B6B5FBA');
$this->addSql('DROP TABLE audit_log');
}
}

View File

@@ -3,57 +3,161 @@
namespace App\Controller\Dashboard;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Event\Object\EventAdminCreate;
use App\Event\Object\EventAdminDeleted;
use App\Form\AccountType;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Repository\AccountRepository;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
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\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Component\Uid\Uuid;
class AccountController extends AbstractController
{
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET','POST'])]
public function administrateur(AccountRepository $accountRepository): Response
/**
* Liste des administrateurs
*/
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET'])]
public function administrateur(AccountRepository $accountRepository, AppLogger $appLogger,EventDispatcherInterface $eventDispatcher): Response
{
// Audit Log : On trace la consultation de la liste
$appLogger->record('VIEW', 'Consultation de la liste des administrateurs');
$eventDispatcher->dispatch(new EventAdminCreate($accountRepository->findAdmin()[0], $this->getUser()));
return $this->render('dashboard/administrateur.twig', [
'admins' => $accountRepository->findAdmin(),
]);
}
/**
* Ajouter un administrateur
*/
#[Route(path: '/crm/administrateur/add', name: 'app_crm_administrateur_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function administrateurAdd(Request $request): Response
{
public function administrateurAdd(
Request $request,
EntityManagerInterface $entityManager,
UserPasswordHasherInterface $passwordHasher,
AppLogger $appLogger,
EventDispatcherInterface $eventDispatcher
): Response {
$account = new Account();
// Initialisation des valeurs par défaut
$account->setIsFirstLogin(true);
$account->setIsActif(false);
$account->setUuid(Uuid::v4()->toRfc4122());
$account->setRoles(['ROLE_ADMIN']);
$form = $this->createForm(AccountType::class, $account);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($form->isSubmitted() && $form->isValid()) {
// Hachage du mot de passe temporaire
$tempPassword = bin2hex(random_bytes(20));
$hashedPassword = $passwordHasher->hashPassword($account, $tempPassword);
$account->setPassword($hashedPassword);
$entityManager->persist($account);
$entityManager->flush();
// Audit Log : Enregistrement de la création
$appLogger->record(
'CREATE',
sprintf("Création de l'administrateur : %s %s (%s)",
$account->getFirstName(),
$account->getName(),
$account->getEmail()
)
);
// Notification : Envoi du mail d'activation
$eventDispatcher->dispatch(new EventAdminCreate($account, $this->getUser()));
$this->addFlash('success', 'Le compte administrateur de ' . $account->getFirstName() . ' a été créé avec succès.');
return $this->redirectToRoute('app_crm_administrateur');
}
return $this->render('dashboard/administrateur/add.twig', [
'form' => $form->createView(),
]);
}
#[Route(path: '/crm/administrateur/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET','POST'])]
public function administrateurView(?Account $account): Response
{
}
#[Route(path: '/crm/administrateur/delete/{id}', name: 'app_crm_administrateur_delete_view', options: ['sitemap' => false], methods: ['GET','POST'])]
public function administrateurDelete(?Account $account): Response
/**
* Voir la fiche d'un administrateur
*/
#[Route(path: '/crm/administrateur/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET'])]
public function administrateurView(?Account $account, AppLogger $appLogger): Response
{
if (!$account) {
$this->addFlash('error', 'Administrateur introuvable.');
return $this->redirectToRoute('app_crm_administrateur');
}
// Audit Log : On trace la consultation d'une fiche spécifique
$appLogger->record(
'VIEW',
sprintf("Consultation du profil de : %s %s (ID: %d)",
$account->getFirstName(),
$account->getName(),
$account->getId()
)
);
return $this->render('dashboard/administrateur/view.twig', [
'admin' => $account,
]);
}
/**
* Supprimer un administrateur
*/
#[Route(path: '/crm/administrateur/delete/{id}', name: 'app_crm_administrateur_delete', options: ['sitemap' => false], methods: ['POST'])]
public function administrateurDelete(
EventDispatcherInterface $eventDispatcher,
?Account $account,
Request $request,
AppLogger $appLogger,
EntityManagerInterface $entityManager
): Response {
if (!$account) {
$this->addFlash('error', 'Administrateur introuvable.');
return $this->redirectToRoute('app_crm_administrateur');
}
if ($account === $this->getUser()) {
$this->addFlash('error', 'Vous ne pouvez pas supprimer votre propre compte.');
return $this->redirectToRoute('app_crm_administrateur');
}
// Récupération du token CSRF (compatible Turbo)
$token = $request->request->get('_token') ?? $request->query->get('_token');
if ($this->isCsrfTokenValid('delete' . $account->getId(), $token)) {
$name = $account->getFirstName() . ' ' . $account->getName();
$email = $account->getEmail();
// Audit Log : On trace avant la suppression pour garder les infos
$appLogger->record(
'DELETE',
sprintf("Suppression définitive de l'administrateur : %s (%s)", $name, $email)
);
// Dispatch pour notification mail
$eventDispatcher->dispatch(new EventAdminDeleted($account, $this->getUser()));
$entityManager->remove($account);
$entityManager->flush();
$this->addFlash('success', "Le compte de $name a été supprimé.");
} else {
$this->addFlash('error', 'Jeton de sécurité invalide.');
}
return $this->redirectToRoute('app_crm_administrateur');
}
}

View File

@@ -6,8 +6,10 @@ use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -121,6 +123,29 @@ class HomeController extends AbstractController
'account' => $account,
]);
}
#[Route('/unscribe/{email}', name: 'app_unscribe', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function appUnscribe(
string $email,
Request $request,
EntityManagerInterface $entityManager,
AppLogger $appLogger
): Response {
// 1. Décodage de l'email (au cas où il y aurait des caractères spéciaux)
$email = urldecode($email);
// 3. Gestion du POST (Désinscription en un clic via le client mail)
if ($request->isMethod('POST')) {
$appLogger->record('UNSUBSCRIBE', sprintf("Désinscription automatique (One-Click) de : %s", $email));
return new JsonResponse(['status' => 'success'], Response::HTTP_OK);
}
$appLogger->record('UNSUBSCRIBE', sprintf("Désinscription manuelle de : %s", $email));
return $this->render('security/unscribe_success.twig', [
'email' => $email
]);
}
const SENTRY_HOST = '';
const SENTRY_PROJECT_IDS = [''];

View File

@@ -69,9 +69,16 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'string', nullable: true)]
private ?string $googleAuthenticatorSecret = null;
/**
* @var Collection<int, AuditLog>
*/
#[ORM\OneToMany(targetEntity: AuditLog::class, mappedBy: 'account')]
private Collection $auditLogs;
public function __construct()
{
$this->accountLoginRegisters = new ArrayCollection();
$this->auditLogs = new ArrayCollection();
}
public function getId(): ?int
@@ -313,4 +320,34 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface
{
$this->googleAuthenticatorSecret = $googleAuthenticatorSecret;
}
/**
* @return Collection<int, AuditLog>
*/
public function getAuditLogs(): Collection
{
return $this->auditLogs;
}
public function addAuditLog(AuditLog $auditLog): static
{
if (!$this->auditLogs->contains($auditLog)) {
$this->auditLogs->add($auditLog);
$auditLog->setAccount($this);
}
return $this;
}
public function removeAuditLog(AuditLog $auditLog): static
{
if ($this->auditLogs->removeElement($auditLog)) {
// set the owning side to null (unless already changed)
if ($auditLog->getAccount() === $this) {
$auditLog->setAccount(null);
}
}
return $this;
}
}

50
src/Entity/AuditLog.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace App\Entity;
use App\Repository\AuditLogRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
class AuditLog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Account $account = null;
#[ORM\Column]
private \DateTimeImmutable $actionAt;
#[ORM\Column(length: 50)]
private ?string $type = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $message = null;
#[ORM\Column(length: 255)]
private ?string $path = null;
// Le constructeur force le remplissage des données dès le départ
public function __construct(Account $account, string $type, string $message, string $path)
{
$this->account = $account;
$this->type = $type;
$this->message = $message;
$this->path = $path;
$this->actionAt = new \DateTimeImmutable();
}
// Uniquement des Getters (Pas de Setters = Pas de modification possible en PHP)
public function getId(): ?int { return $this->id; }
public function getAccount(): ?Account { return $this->account; }
public function getActionAt(): \DateTimeImmutable { return $this->actionAt; }
public function getType(): ?string { return $this->type; }
public function getMessage(): ?string { return $this->message; }
public function getPath(): ?string { return $this->path; }
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Event\Object;
use App\Entity\Account;
class EventAdminCreate
{
private Account $account;
private Account $requestedAccount;
public function __construct(Account $account,Account $requestedAccount)
{
$this->account = $account;
$this->requestedAccount = $requestedAccount;
}
/**
* @return Account
*/
public function getAccount(): Account
{
return $this->account;
}
public function getRequestedAccount()
{
return $this->requestedAccount;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Event\Object;
use App\Entity\Account;
class EventAdminDeleted
{
private Account $account;
private Account $requestedAccount;
public function __construct(Account $account,Account $requestedAccount)
{
$this->account = $account;
$this->requestedAccount = $requestedAccount;
}
/**
* @return Account
*/
public function getAccount(): Account
{
return $this->account;
}
public function getRequestedAccount()
{
return $this->requestedAccount;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Event\Service;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Event\Object\EventAdminCreate;
use App\Event\Object\EventAdminDeleted;
use App\Service\Generator\TempPasswordGenerator;
use App\Service\Mailer\Mailer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsEventListener(event: EventAdminDeleted::class, method: 'onAdminDeleted')]
#[AsEventListener(event: EventAdminCreate::class, method: 'onAdminCreate')]
class AdminEvent
{
public function __construct(
private readonly Mailer $mailer,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly EntityManagerInterface $entityManager,
) {
}
public function onAdminCreate(EventAdminCreate $eventAdminCreate): void
{
$account = $eventAdminCreate->getAccount();
// On cherche si une demande existe déjà pour cet utilisateur
$existingRequest = $this->entityManager->getRepository(AccountResetPasswordRequest::class)
->findOneBy(['Account' => $account]);
$now = new \DateTimeImmutable();
$sendNewRequest = true;
$request = null;
if ($existingRequest instanceof AccountResetPasswordRequest) {
if ($existingRequest->getExpiresAt() < $now) {
$this->entityManager->remove($existingRequest);
$this->entityManager->flush();
} else {
$sendNewRequest = false;
$request = $existingRequest;
}
}
if ($sendNewRequest) {
$expiredAt = $now->modify('+24 hours'); // On donne 24h pour une création de compte
$request = new AccountResetPasswordRequest();
$request->setAccount($account);
$request->setToken(TempPasswordGenerator::generate(50, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'));
$request->setRequestedAt($now);
$request->setExpiresAt($expiredAt);
$this->entityManager->persist($request);
$this->entityManager->flush();
}
// Génération du lien de confirmation
$resetLink = $this->urlGenerator->generate(
'app_forgot_password_confirm',
['id' => $account->getId(), 'token' => $request->getToken()],
UrlGeneratorInterface::ABSOLUTE_URL
);
// Envoi du mail de bienvenue / activation
$this->mailer->send(
$account->getEmail(),
$account->getFirstName() . ' ' . $account->getName(),
"[Intranet Ludikevent] Activation de votre accès administrateur",
"mails/notification/admin_created.twig",
[
'admin' => $account,
'creator' => $eventAdminCreate->getRequestedAccount(),
'setup_url' => $resetLink,
'expires_at' => $request->getExpiresAt(),
]
);
}
public function onAdminDeleted(EventAdminDeleted $eventAdminDeleted): void
{
$context = [
'admin' => $eventAdminDeleted->getAccount(),
'who' => $eventAdminDeleted->getRequestedAccount(),
'date' => new \DateTimeImmutable(),
];
$recipients = [
'notification@siteconseil.fr' => "Notification Intranet Ludikevent",
'contact@ludikevent.fr' => "Notification Intranet Ludikevent"
];
foreach ($recipients as $email => $name) {
$this->mailer->send(
$email,
$name,
"[Intranet Ludikevent] - Suppression d'un administrateur",
"mails/notification/admin_deleted.twig",
$context
);
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Event\Service;
use App\Entity\Account;
use App\Entity\AccountLoginRegister;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
#[AsEventListener(event: LoginSuccessEvent::class, method: 'onLoginSuccess')]
class LoginStatsSubscriber
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$user = $event->getUser();
// On ne loggue que si c'est bien une instance de notre entité Account
if (!$user instanceof Account) {
return;
}
$request = $event->getRequest();
$loginRegister = new AccountLoginRegister();
$loginRegister->setAccount($user);
$loginRegister->setLoginAt(new \DateTimeImmutable());
$loginRegister->setIp($request->getClientIp());
$loginRegister->setUserAgent($request->headers->get('User-Agent'));
$this->entityManager->persist($loginRegister);
$this->entityManager->flush();
}
}

35
src/Logger/AppLogger.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Logger;
use App\Entity\Account;
use App\Entity\AuditLog;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
class AppLogger
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly RequestStack $requestStack
) {}
public function record(string $type, string $message): void
{
$user = $this->security->getUser();
if (!$user instanceof Account) {
return;
}
$request = $this->requestStack->getCurrentRequest();
$path = $request ? $request->getRequestUri() : 'CLI/Internal';
// Création de l'objet immuable via le constructeur
$log = new AuditLog($user, $type, $message, $path);
$this->entityManager->persist($log);
$this->entityManager->flush();
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Logger;
use App\Entity\AuditLog;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\PreUpdateEventArgs;
#[AsEntityListener(event: Events::preUpdate, method: 'preUpdate', entity: AuditLog::class)]
#[AsEntityListener(event: Events::preRemove, method: 'preRemove', entity: AuditLog::class)]
class AuditLogSecurityListener
{
public function preUpdate(AuditLog $log, PreUpdateEventArgs $event): void
{
throw new \LogicException("AuditLog est immuable : modification interdite.");
}
public function preRemove(AuditLog $log): void
{
throw new \LogicException("AuditLog est protégé : suppression interdite.");
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\AuditLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AuditLog>
*/
class AuditLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AuditLog::class);
}
// /**
// * @return AuditLog[] Returns an array of AuditLog objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('a.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?AuditLog
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -54,6 +54,7 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio
$existingUser = $this->entityManager->getRepository(Account::class)->findOneBy(['keycloakId' => $keycloakUser->getId()]);
if ($existingUser) {
return $existingUser;
}

View File

@@ -1,6 +1,5 @@
<?php
namespace App\Service\Mailer;
use Doctrine\ORM\EntityManagerInterface;
@@ -25,13 +24,12 @@ class Mailer
private readonly UrlGeneratorInterface $urlGenerator,
private readonly ?Profiler $profiler,
private readonly Environment $environment,
)
{
) {
$this->mailer = $mailer;
}
/**
* Convertit le contenu MJML en HTML via le binaire MJML installé sur le serveur.
* Convertit le contenu MJML en HTML via le binaire MJML.
*/
private function convertMjmlToHtml(string $mjmlContent): string
{
@@ -52,13 +50,7 @@ class Mailer
}
/**
* Envoie un email avec headers de désinscription et Message-ID personnalisé.
* * @param string $address Email du destinataire
* @param string $addressName Nom du destinataire
* @param string $subject Sujet de l'email
* @param string $template Chemin du template Twig/MJML
* @param array<string, mixed> $data Données à passer au template
* @param array<\Symfony\Component\Mime\Part\DataPart> $files Pièces jointes
* Envoie un email avec headers de désinscription et Message-ID conforme RFC 2822.
*/
public function send(
string $address,
@@ -67,16 +59,15 @@ class Mailer
string $template,
array $data,
array $files = []
): void
{
): void {
$domain = "ludikevent.fr";
$dest = new Address($address, $addressName);
$src = new Address("contact@" . $domain, "Ludikevent");
// 1. Génération du Message-ID unique
$messageId = sprintf('<%s.%s@%s>',
bin2hex(random_bytes(12)),
// 1. Génération du Message-ID (SANS les crochets < >, Symfony les ajoute)
$messageId = sprintf('%s.%s@%s',
time(),
bin2hex(random_bytes(8)),
$domain
);
@@ -88,30 +79,30 @@ class Mailer
->to($dest)
->from($src);
// 3. Configuration des Headers techniques
// 3. Configuration des Headers
$headers = $mail->getHeaders();
// Identification unique
// addIdHeader entoure automatiquement la valeur de < >
$headers->addIdHeader('Message-ID', $messageId);
// RFC 2369 & 8058 (Bouton désinscription dans le client mail)
// RFC 2369 & 8058
$headers->addTextHeader('List-Unsubscribe', sprintf('<mailto:unscribe@%s>, <%s>', $domain, $unsubscribeUrl));
$headers->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
// 4. Génération du contenu via Twig et MJML
// 4. Génération du contenu MJML
$mjmlGenerator = $this->environment->render($template, [
'system' => [
'subject' => $subject,
'path' => $_ENV['PATH_URL'] ?? 'https://intranet.ludikevent.fr',
'path' => $_ENV['PATH_URL'] ?? 'https://ludikevent.fr',
'unsubscribe_url' => $unsubscribeUrl,
'message_id' => $messageId
'message_id' => $messageId // Ici on peut l'afficher pour info
],
'datas' => $data,
]);
$htmlContent = $this->convertMjmlToHtml($mjmlGenerator);
// 5. Version Texte Brut (Fallback)
// 5. Fallback Texte Brut
try {
$txtContent = $this->environment->render('txt-' . $template, [
'system' => [
@@ -123,21 +114,18 @@ class Mailer
]);
$mail->text($txtContent);
} catch (Exception) {
// Si le template TXT n'existe pas, on laisse le mailer gérer le fallback ou on ignore
// Optionnel : générer un texte brut basique si le template n'existe pas
}
// 6. Ajout des pièces jointes
foreach ($files as $file) {
$mail->addPart($file);
}
$mail->html($htmlContent);
// 7. Envoi final
try {
$this->mailer->send($mail);
} catch (TransportExceptionInterface $e) {
// Optionnel : Logger l'erreur ici
throw $e;
}
}

View File

@@ -71,7 +71,7 @@ class ResetPasswordSubscriber
$this->mailer->send(
$account->getEmail(),
$account->getUsername(),
'[CRM] - Lien pour réinitialiser votre mot de passe',
'[Intranet Ludikevent] - Lien pour réinitialiser votre mot de passe',
'mails/reset.twig',
[
'account' => $account,

View File

@@ -19,7 +19,7 @@
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800 dark:text-white">Liste des Administrateurs</h1>
<span class="px-3 py-1 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-200">
{{ admins|length }} membres
{{ admins|length }} Administrateurs
</span>
</div>
@@ -67,7 +67,7 @@
</a>
{# Bouton Supprimer #}
<a href="{{ path('app_crm_administrateur_delete_view', {id: admin.id}) }}"
<a href="{{ path('app_crm_administrateur_delete', {id: admin.id}) }}?_token={{ csrf_token('delete' ~ admin.id) }}"
data-turbo-method="post"
data-turbo-confirm="Êtes-vous sûr de vouloir supprimer cet administrateur ?"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-red-600 bg-red-100/10 border border-red-600/20 rounded-lg hover:bg-red-600 hover:text-white transition-all duration-200 dark:text-red-500 dark:bg-red-900/20 dark:border-red-800/50 dark:hover:bg-red-600 dark:hover:text-white">

View File

@@ -0,0 +1,63 @@
Voici le code MJML complet pour tes notifications d'administration. J'ai conçu un template élégant, responsive et aux couleurs de Ludikevent, incluant les deux variantes : la création (avec bouton d'activation) et la suppression.
1. Template : mails/notification/admin_created.twig (MJML)
Ce template utilise les variables passées par ton service AdminEvent (datas.setup_url, datas.admin, etc.).
XML
<mjml>
<mj-head>
<mj-title>[Intranet Ludikevent] Activation de compte</mj-title>
<mj-attributes>
<mj-all font-family="Arial, sans-serif" />
<mj-text font-size="16px" color="#333333" line-height="24px" />
<mj-section background-color="#ffffff" padding="20px" />
<mj-button background-color="#007bff" color="white" font-size="16px" font-weight="bold" border-radius="5px" cursor="pointer" />
</mj-attributes>
<mj-style>
.header-bg { background-color: #1a1a1a !important; }
.footer-text { font-size: 12px !important; color: #888888 !important; }
</mj-style>
</mj-head>
<mj-body background-color="#f4f4f4">
<mj-section css-class="header-bg" padding="10px">
<mj-column>
<mj-text align="center" color="#ffffff" font-size="20px" font-weight="bold">
LUDIKEVENT INTRANET
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="22px" font-weight="bold" color="#007bff">
Bienvenue, {{ datas.admin.firstName }} !
</mj-text>
<mj-text>
Un accès administrateur vient d'être créé pour vous sur l'Intranet Ludikevent par <strong>{{ datas.creator.firstName }} {{ datas.creator.name }}</strong>.
</mj-text>
<mj-text>
Pour finaliser la configuration de votre compte et choisir votre mot de passe, veuillez cliquer sur le bouton ci-dessous :
</mj-text>
<mj-button href="{{ datas.setup_url }}">
ACTIVER MON COMPTE
</mj-button>
<mj-text font-size="14px" color="#666666">
Ce lien est valable pendant 24 heures (expire le {{ datas.expires_at|date('d/m/Y à H:i') }}).
</mj-text>
<mj-divider border-width="1px" border-color="#eeeeee" />
<mj-text font-size="14px">
Si vous n'êtes pas à l'origine de cette demande, veuillez ignorer cet email.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#f4f4f4">
<mj-column>
<mj-text align="center" css-class="footer-text">
&copy; {{ "now"|date("Y") }} Ludikevent - Système de notification automatique<br/>
Ne pas répondre à cet email.
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,38 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section padding-top="0px">
<mj-column>
<mj-text font-size="18px" font-weight="bold" color="#dc2626">
Alerte de suppression de compte
</mj-text>
<mj-text>
Une action de suppression définitive a été effectuée sur l'intranet LudikEvent.
</mj-text>
<mj-table border="1px solid #e5e7eb" cellpadding="10px">
<tr style="background-color:#f9fafb; text-align:left;">
<th style="padding: 10px;">Information</th>
<th style="padding: 10px;">Détails</th>
</tr>
<tr>
<td style="font-weight:bold;">Compte supprimé</td>
<td>{{ datas.admin.firstName }} {{ datas.admin.name }} ({{ datas.admin.email }})</td>
</tr>
<tr>
<td style="font-weight:bold;">Supprimé par</td>
<td>{{ datas.who.firstName }} {{ datas.who.name }}</td>
</tr>
<tr>
<td style="font-weight:bold;">Date et heure</td>
<td>{{ datas.date|date('d/m/Y à H:i') }}</td>
</tr>
</mj-table>
<mj-text color="#6b7280" font-size="13px" padding-top="20px">
Cette action est irréversible. Toutes les données associées à ce compte (historique de connexion, permissions spécifiques) ont été retirées de la base de données.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -72,4 +72,4 @@
</div>
</div>
{% endblock %
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends 'base.twig' %}
{% block title %}{{ 'email.removed'|trans }}{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-xl shadow-lg text-center">
{# Logo #}
<div class="flex justify-center">
<img class="h-16 w-auto" src="{{ asset('assets/images/logo.png') }}" alt="Ludikevent Logo"/>
</div>
{# Icône de succès #}
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
{# Message #}
<div class="space-y-4">
<h2 class="text-2xl font-extrabold text-gray-900">
{{ 'email.removed'|trans }}
</h2>
<p class="text-sm text-gray-600">
L'adresse email <strong>{{ email }}</strong> a bien été retirée de notre liste de diffusion. Vous ne recevrez plus de notifications automatiques de l'intranet.
</p>
</div>
<p class="text-xs text-gray-400 pt-4">
&copy; {{ "now"|date("Y") }} Ludikevent. Tous droits réservés.
</p>
</div>
</div>
{% endblock %}

View File

@@ -29,3 +29,4 @@ logout_link: Déconnexion
page.login: Connexion
logged_admin: Administration
button.sso: Connexion SSO
email.removed: Email supprimée