```
✨ 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:
36
migrations/Version20260115183138.php
Normal file
36
migrations/Version20260115183138.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,57 +3,161 @@
|
|||||||
namespace App\Controller\Dashboard;
|
namespace App\Controller\Dashboard;
|
||||||
|
|
||||||
use App\Entity\Account;
|
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\AccountType;
|
||||||
use App\Form\RequestPasswordConfirmType;
|
use App\Logger\AppLogger;
|
||||||
use App\Form\RequestPasswordRequestType;
|
|
||||||
use App\Repository\AccountRepository;
|
use App\Repository\AccountRepository;
|
||||||
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
||||||
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\Security\Http\Authentication\AuthenticationUtils;
|
use Symfony\Component\Uid\Uuid;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
||||||
|
|
||||||
|
|
||||||
class AccountController extends AbstractController
|
class AccountController extends AbstractController
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET','POST'])]
|
* Liste des administrateurs
|
||||||
public function administrateur(AccountRepository $accountRepository): Response
|
*/
|
||||||
|
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET'])]
|
||||||
|
public function administrateur(AccountRepository $accountRepository, AppLogger $appLogger,EventDispatcherInterface $eventDispatcher): Response
|
||||||
{
|
{
|
||||||
return $this->render('dashboard/administrateur.twig',[
|
// 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(),
|
'admins' => $accountRepository->findAdmin(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
#[Route(path: '/crm/administrateur/add', name: 'app_crm_administrateur_add', options: ['sitemap' => false], methods: ['GET','POST'])]
|
|
||||||
public function administrateurAdd(Request $request): Response
|
/**
|
||||||
{
|
* Ajouter un administrateur
|
||||||
|
*/
|
||||||
|
#[Route(path: '/crm/administrateur/add', name: 'app_crm_administrateur_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||||
|
public function administrateurAdd(
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $entityManager,
|
||||||
|
UserPasswordHasherInterface $passwordHasher,
|
||||||
|
AppLogger $appLogger,
|
||||||
|
EventDispatcherInterface $eventDispatcher
|
||||||
|
): Response {
|
||||||
$account = new Account();
|
$account = new Account();
|
||||||
|
|
||||||
|
// Initialisation des valeurs par défaut
|
||||||
$account->setIsFirstLogin(true);
|
$account->setIsFirstLogin(true);
|
||||||
$account->setIsActif(false);
|
$account->setIsActif(false);
|
||||||
|
$account->setUuid(Uuid::v4()->toRfc4122());
|
||||||
|
$account->setRoles(['ROLE_ADMIN']);
|
||||||
|
|
||||||
$form = $this->createForm(AccountType::class, $account);
|
$form = $this->createForm(AccountType::class, $account);
|
||||||
$form->handleRequest($request);
|
$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', [
|
return $this->render('dashboard/administrateur/add.twig', [
|
||||||
'form' => $form->createView(),
|
'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
|
|
||||||
{
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
#[Route(path: '/crm/administrateur/delete/{id}', name: 'app_crm_administrateur_delete_view', options: ['sitemap' => false], methods: ['GET','POST'])]
|
|
||||||
public function administrateurDelete(?Account $account): Response
|
|
||||||
{
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ use App\Entity\Account;
|
|||||||
use App\Entity\AccountResetPasswordRequest;
|
use App\Entity\AccountResetPasswordRequest;
|
||||||
use App\Form\RequestPasswordConfirmType;
|
use App\Form\RequestPasswordConfirmType;
|
||||||
use App\Form\RequestPasswordRequestType;
|
use App\Form\RequestPasswordRequestType;
|
||||||
|
use App\Logger\AppLogger;
|
||||||
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
||||||
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
|
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
@@ -121,6 +123,29 @@ class HomeController extends AbstractController
|
|||||||
'account' => $account,
|
'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_HOST = '';
|
||||||
const SENTRY_PROJECT_IDS = [''];
|
const SENTRY_PROJECT_IDS = [''];
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,16 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Column(type: 'string', nullable: true)]
|
#[ORM\Column(type: 'string', nullable: true)]
|
||||||
private ?string $googleAuthenticatorSecret = null;
|
private ?string $googleAuthenticatorSecret = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, AuditLog>
|
||||||
|
*/
|
||||||
|
#[ORM\OneToMany(targetEntity: AuditLog::class, mappedBy: 'account')]
|
||||||
|
private Collection $auditLogs;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->accountLoginRegisters = new ArrayCollection();
|
$this->accountLoginRegisters = new ArrayCollection();
|
||||||
|
$this->auditLogs = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -313,4 +320,34 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
{
|
{
|
||||||
$this->googleAuthenticatorSecret = $googleAuthenticatorSecret;
|
$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
50
src/Entity/AuditLog.php
Normal 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; }
|
||||||
|
}
|
||||||
30
src/Event/Object/EventAdminCreate.php
Normal file
30
src/Event/Object/EventAdminCreate.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Event/Object/EventAdminDeleted.php
Normal file
30
src/Event/Object/EventAdminDeleted.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/Event/Service/AdminEvent.php
Normal file
106
src/Event/Service/AdminEvent.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/Event/Service/LoginStatsSubscriber.php
Normal file
39
src/Event/Service/LoginStatsSubscriber.php
Normal 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
35
src/Logger/AppLogger.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Logger/AuditLogSecurityListener.php
Normal file
23
src/Logger/AuditLogSecurityListener.php
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Repository/AuditLogRepository.php
Normal file
43
src/Repository/AuditLogRepository.php
Normal 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()
|
||||||
|
// ;
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio
|
|||||||
$existingUser = $this->entityManager->getRepository(Account::class)->findOneBy(['keycloakId' => $keycloakUser->getId()]);
|
$existingUser = $this->entityManager->getRepository(Account::class)->findOneBy(['keycloakId' => $keycloakUser->getId()]);
|
||||||
|
|
||||||
if ($existingUser) {
|
if ($existingUser) {
|
||||||
|
|
||||||
return $existingUser;
|
return $existingUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
namespace App\Service\Mailer;
|
namespace App\Service\Mailer;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -20,18 +19,17 @@ class Mailer
|
|||||||
private readonly MailerInterface $mailer;
|
private readonly MailerInterface $mailer;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
MailerInterface $mailer,
|
MailerInterface $mailer,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly UrlGeneratorInterface $urlGenerator,
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
private readonly ?Profiler $profiler,
|
private readonly ?Profiler $profiler,
|
||||||
private readonly Environment $environment,
|
private readonly Environment $environment,
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
$this->mailer = $mailer;
|
$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
|
private function convertMjmlToHtml(string $mjmlContent): string
|
||||||
{
|
{
|
||||||
@@ -52,31 +50,24 @@ class Mailer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Envoie un email avec headers de désinscription et Message-ID personnalisé.
|
* Envoie un email avec headers de désinscription et Message-ID conforme RFC 2822.
|
||||||
* * @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
|
|
||||||
*/
|
*/
|
||||||
public function send(
|
public function send(
|
||||||
string $address,
|
string $address,
|
||||||
string $addressName,
|
string $addressName,
|
||||||
string $subject,
|
string $subject,
|
||||||
string $template,
|
string $template,
|
||||||
array $data,
|
array $data,
|
||||||
array $files = []
|
array $files = []
|
||||||
): void
|
): void {
|
||||||
{
|
|
||||||
$domain = "ludikevent.fr";
|
$domain = "ludikevent.fr";
|
||||||
$dest = new Address($address, $addressName);
|
$dest = new Address($address, $addressName);
|
||||||
$src = new Address("contact@" . $domain, "Ludikevent");
|
$src = new Address("contact@" . $domain, "Ludikevent");
|
||||||
|
|
||||||
// 1. Génération du Message-ID unique
|
// 1. Génération du Message-ID (SANS les crochets < >, Symfony les ajoute)
|
||||||
$messageId = sprintf('<%s.%s@%s>',
|
$messageId = sprintf('%s.%s@%s',
|
||||||
bin2hex(random_bytes(12)),
|
|
||||||
time(),
|
time(),
|
||||||
|
bin2hex(random_bytes(8)),
|
||||||
$domain
|
$domain
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,30 +79,30 @@ class Mailer
|
|||||||
->to($dest)
|
->to($dest)
|
||||||
->from($src);
|
->from($src);
|
||||||
|
|
||||||
// 3. Configuration des Headers techniques
|
// 3. Configuration des Headers
|
||||||
$headers = $mail->getHeaders();
|
$headers = $mail->getHeaders();
|
||||||
|
|
||||||
// Identification unique
|
// addIdHeader entoure automatiquement la valeur de < >
|
||||||
$headers->addIdHeader('Message-ID', $messageId);
|
$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', sprintf('<mailto:unscribe@%s>, <%s>', $domain, $unsubscribeUrl));
|
||||||
$headers->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
|
$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, [
|
$mjmlGenerator = $this->environment->render($template, [
|
||||||
'system' => [
|
'system' => [
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
'path' => $_ENV['PATH_URL'] ?? 'https://intranet.ludikevent.fr',
|
'path' => $_ENV['PATH_URL'] ?? 'https://ludikevent.fr',
|
||||||
'unsubscribe_url' => $unsubscribeUrl,
|
'unsubscribe_url' => $unsubscribeUrl,
|
||||||
'message_id' => $messageId
|
'message_id' => $messageId // Ici on peut l'afficher pour info
|
||||||
],
|
],
|
||||||
'datas' => $data,
|
'datas' => $data,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$htmlContent = $this->convertMjmlToHtml($mjmlGenerator);
|
$htmlContent = $this->convertMjmlToHtml($mjmlGenerator);
|
||||||
|
|
||||||
// 5. Version Texte Brut (Fallback)
|
// 5. Fallback Texte Brut
|
||||||
try {
|
try {
|
||||||
$txtContent = $this->environment->render('txt-' . $template, [
|
$txtContent = $this->environment->render('txt-' . $template, [
|
||||||
'system' => [
|
'system' => [
|
||||||
@@ -123,21 +114,18 @@ class Mailer
|
|||||||
]);
|
]);
|
||||||
$mail->text($txtContent);
|
$mail->text($txtContent);
|
||||||
} catch (Exception) {
|
} 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) {
|
foreach ($files as $file) {
|
||||||
$mail->addPart($file);
|
$mail->addPart($file);
|
||||||
}
|
}
|
||||||
|
|
||||||
$mail->html($htmlContent);
|
$mail->html($htmlContent);
|
||||||
|
|
||||||
// 7. Envoi final
|
|
||||||
try {
|
try {
|
||||||
$this->mailer->send($mail);
|
$this->mailer->send($mail);
|
||||||
} catch (TransportExceptionInterface $e) {
|
} catch (TransportExceptionInterface $e) {
|
||||||
// Optionnel : Logger l'erreur ici
|
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class ResetPasswordSubscriber
|
|||||||
$this->mailer->send(
|
$this->mailer->send(
|
||||||
$account->getEmail(),
|
$account->getEmail(),
|
||||||
$account->getUsername(),
|
$account->getUsername(),
|
||||||
'[CRM] - Lien pour réinitialiser votre mot de passe',
|
'[Intranet Ludikevent] - Lien pour réinitialiser votre mot de passe',
|
||||||
'mails/reset.twig',
|
'mails/reset.twig',
|
||||||
[
|
[
|
||||||
'account' => $account,
|
'account' => $account,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="flex justify-between items-center mb-6">
|
<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>
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# Bouton Supprimer #}
|
{# 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-method="post"
|
||||||
data-turbo-confirm="Êtes-vous sûr de vouloir supprimer cet administrateur ?"
|
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">
|
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">
|
||||||
|
|||||||
63
templates/mails/notification/admin_created.twig
Normal file
63
templates/mails/notification/admin_created.twig
Normal 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">
|
||||||
|
© {{ "now"|date("Y") }} Ludikevent - Système de notification automatique<br/>
|
||||||
|
Ne pas répondre à cet email.
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
38
templates/mails/notification/admin_deleted.twig
Normal file
38
templates/mails/notification/admin_deleted.twig
Normal 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 %}
|
||||||
@@ -72,4 +72,4 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %
|
{% endblock %}
|
||||||
|
|||||||
37
templates/security/unscribe_success.twig
Normal file
37
templates/security/unscribe_success.twig
Normal 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">
|
||||||
|
© {{ "now"|date("Y") }} Ludikevent. Tous droits réservés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -29,3 +29,4 @@ logout_link: Déconnexion
|
|||||||
page.login: Connexion
|
page.login: Connexion
|
||||||
logged_admin: Administration
|
logged_admin: Administration
|
||||||
button.sso: Connexion SSO
|
button.sso: Connexion SSO
|
||||||
|
email.removed: Email supprimée
|
||||||
|
|||||||
Reference in New Issue
Block a user