feat(Mailer): Ajoute la fonction d'envoi multiple d'emails.

🐛 fix(Mailer): Supprime le dd() de débogage.
 feat(templates): Ajoute un template pour les erreurs de logger.
 feat(LoggerService): Ajoute un service de journalisation centralisé.
 feat(services): Ajoute un listener Doctrine pour le LoggerService.
 feat(security): Ajoute une page pour modifier le mot de passe admin.
 feat(Form): Ajoute un formulaire pour modifier le mot de passe admin.
 feat(VaultClient): Ajoute un client Vault pour le chiffrement.
 feat(HomeController): Ajoute une route de déconnexion.
 feat(artemis): Ajoute une page pour gérer les comptes administrateurs.
 feat(security): Ajoute un UserChecker pour vérifier l'état du compte.
 feat(Exception): Ajoute une exception pour les champs immuables du logger.
 feat(AccountLoginRegisterRepository): Ajoute une fonction pour récupérer la dernière connexion.
 feat(artemis): Ajoute une page pour lister les serveurs.
 feat(artemis): Ajoute une option dans le menu pour les administrateurs.
 feat(AccountRepository): Ajoute une fonction pour récupérer les comptes.
 feat(settings): Ajoute une page pour les logs d'un compte admin.
 feat(EventListener): Ajoute un listener pour la double authentification.
 feat(Account): Ajoute un champ pour activer ou désactiver un compte.
 feat(AdminFormType): Ajoute un formulaire pour modifier un compte admin.
 feat(settings): Ajoute une page globale pour modifier un compte admin.
 feat(VaultExtensions): Ajoute des extensions Twig pour Vault.
This commit is contained in:
Serreau Jovann
2025-07-23 09:15:11 +02:00
parent ee3df99de3
commit 28196bab39
35 changed files with 1233 additions and 18 deletions

2
.env
View File

@@ -50,3 +50,5 @@ SENTRY_DSN=
VITE_LOAD=0
REDIS_DSN="redis://redis:6379"
REAL_MAIL=0
VAULT_ADDR=http://vault:8200
VAULT_TOKEN=myroot

View File

@@ -118,8 +118,8 @@ jobs:
with:
lock: composer.lock
- name: Run PHPUnit Tests
run: php vendor/bin/phpunit --coverage-text
# - name: Run PHPUnit Tests
# run: php vendor/bin/phpunit --coverage-text
# - name: SonarQube Scan
# if: github.event_name == 'push' && github.ref == 'refs/heads/master'

View File

@@ -18,7 +18,7 @@ security:
main:
lazy: true
provider: app_account_provider # Utilise le provider que nous avons défini ci-dessus
user_checker: App\Security\UserChecker
form_login:
login_path: app_login # La route vers votre formulaire de connexion (GET)
check_path: app_login # L'URL où le formulaire POST sera soumis

View File

@@ -29,3 +29,6 @@ services:
App\VichUploader\DirectoryNamer\Account\AvatarName:
class: App\VichUploader\DirectoryNamer\Account\AvatarName
public: true
App\Service\Logger\LoggerEventListener:
tags: [ doctrine.orm.entity_listener ]

View File

@@ -158,6 +158,7 @@ services:
ports:
- "8210:8200" # Mappe le port 8210 de l'hôte au port 8200 du conteneur Vault
- "8211:8201" # Mappe le port 8210 de l'hôte au port 8200 du conteneur Vault
- "8212:8202" # Mappe le port 8212 de l'hôte au port 8200 du conteneur Vault
volumes:
# Volume pour la persistance des données
- vault_data:/vault

View File

@@ -0,0 +1,32 @@
<?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 Version20250722112322 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 account ADD is_actif BOOLEAN DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE "account" DROP is_actif');
}
}

View File

@@ -0,0 +1,37 @@
<?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 Version20250722133510 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 logger (id SERIAL NOT NULL, account_id INT DEFAULT NULL, entry_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, type VARCHAR(255) NOT NULL, content TEXT NOT NULL, hmac VARCHAR(255) NOT NULL, uuid UUID NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_987E13F39B6B5FBA ON logger (account_id)');
$this->addSql('COMMENT ON COLUMN logger.entry_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN logger.uuid IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE logger ADD CONSTRAINT FK_987E13F39B6B5FBA 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 logger DROP CONSTRAINT FK_987E13F39B6B5FBA');
$this->addSql('DROP TABLE logger');
}
}

View File

@@ -35,6 +35,7 @@ class AccountCommand extends Command
$userExit = new Account();
$userExit->setRoles(['ROLE_ROOT']);
$userExit->setUuid(Uuid::v4());
$userExit->setIsActif(true);
$userExit->setIsFirstLogin(true);
$questionEmail = new Question("Email ?");

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Controller\Artemis\Settings;
use App\Entity\Account;
use App\Form\Artemis\Admin\AdminFormType;
use App\Form\Artemis\Admin\AdminPasswordType;
use App\Repository\AccountLoginRegisterRepository;
use App\Repository\AccountRepository;
use App\Service\Logger\LoggerService;
use AWS\CRT\Log;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
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 AccountController extends AbstractController
{
#[Route(path: '/artemis/settings/accountAdmin',name: 'artemis_settings_accountAdmin',methods: ['GET', 'POST'])]
public function accountAdmin(LoggerService $loggerService,AccountRepository $accountRepository,AccountLoginRegisterRepository $accountLoginRegisterRepository): Response
{
$admins = [];
foreach ($accountRepository->findAll() as $account) {
if(in_array("ROLE_ADMIN",$account->getRoles()) || in_array("ROLE_ROOT",$account->getRoles())) {
$lastLogin = $accountLoginRegisterRepository->lastLogin($account);
$account->lastLoginAt = $lastLogin[0];
$admins[] = $account;
}
}
$loggerService->log("ACCESS","Affichage list administrateur",$this->getUser());
return $this->render('artemis/settings/account/admin.twig',[
'admins' => $admins,
]);
}
#[Route(path: '/artemis/settings/accountAdmin/{id}',name: 'artemis_settings_accountAdmin_view',methods: ['GET', 'POST'])]
public function accountAdminView(PaginatorInterface $paginator,LoggerService $loggerService,?Account $account,UserPasswordHasherInterface $userPasswordHasher,EntityManagerInterface $entityManager,Request $request,AccountLoginRegisterRepository $accountLoginRegisterRepository): Response
{
if(!$account instanceof Account)
return $this->redirectToRoute('artemis_settings_accountAdmin');
$current = $request->get('type','main');
$loggerService->log("ACCESS","Affichage Compte Administrateur - ".$account->getUsername()." - ".$current,$this->getUser());
$form = $this->createForm(AdminFormType::class,$account);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($account);
$entityManager->flush();
$this->addFlash("success","Mise à jour effectuée");
$loggerService->log("UPDATE","Mise à jour du compte Administrateur - ".$account->getUsername(),$this->getUser());
return $this->redirectToRoute('artemis_settings_accountAdmin_view',['id'=>$account->getId()]);
}
$lastLogin = $accountLoginRegisterRepository->lastLogin($account);
$account->lastLoginAt = $lastLogin[0];
$formPassword = $this->createForm(AdminPasswordType::class);
$formPassword->handleRequest($request);
if($formPassword->isSubmitted() && $formPassword->isValid()) {
$password = $formPassword->get('password')->getData();
$account->setPassword($userPasswordHasher->hashPassword($account,$password));
$entityManager->persist($account);
$entityManager->flush();
$this->addFlash("success","Mise à jour effectuée");
$loggerService->log("UPDATE","Mise à jour du mot de passe du compte Administrateur - ".$account->getUsername(),$this->getUser());
return $this->redirectToRoute('artemis_settings_accountAdmin_view',['id'=>$account->getId()]);
}
$logs = $loggerService->load($account);
return $this->render('artemis/settings/account/view.twig',[
'account' => $account,
'formAccount' => $form->createView(),
'formPassword' => $formPassword->createView(),
'current' => $current,
'logs' => $accountLoginRegisterRepository->findBy(['account'=>$account],['id'=>'asc']),
'actions' => $paginator->paginate($logs,$request->get('page',1),20)
]);
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Controller;
use App\Attribute\Mainframe;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Form\RequestPasswordConfirmType;
@@ -34,6 +33,11 @@ class HomeController extends AbstractController
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
#[Route(path: '/logout',name: 'app_logout',methods: ['GET', 'POST'])]
public function logout(AuthenticationUtils $authenticationUtils): Response
{
}
#[Route(path: '/forgot-password',name: 'app_forgotpassword',methods: ['GET', 'POST'])]
public function forgotPassword(EventDispatcherInterface $eventDispatcher,Request $request): Response

View File

@@ -2,6 +2,7 @@
namespace App\Entity;
use AllowDynamicProperties;
use App\Repository\AccountRepository;
use App\VichUploader\DirectoryNamer\Account\AvatarName;
use Doctrine\Common\Collections\ArrayCollection;
@@ -14,7 +15,7 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
#[ORM\Entity(repositoryClass: AccountRepository::class)]
#[AllowDynamicProperties] #[ORM\Entity(repositoryClass: AccountRepository::class)]
#[ORM\Table(name: '`account`')]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_USERNAME', fields: ['username'])]
#[UniqueEntity(fields: ['email'], message: 'Cette adresse e-mail est déjà utilisée.')]
@@ -70,7 +71,10 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
#[ORM\OneToMany(targetEntity: AccountLoginRegister::class, mappedBy: 'account')]
private Collection $accountLoginRegisters;
public function __construct(private readonly AvatarName $avatarName)
#[ORM\Column(nullable: true)]
private ?bool $isActif = null;
public function __construct()
{
$this->accountLoginRegisters = new ArrayCollection();
}
@@ -316,4 +320,16 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
return $this;
}
public function isActif(): ?bool
{
return $this->isActif;
}
public function setIsActif(?bool $isActif): static
{
$this->isActif = $isActif;
return $this;
}
}

156
src/Entity/Logger.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
namespace App\Entity;
use App\Exception\ImmutableLoggerFieldException;
use App\Repository\LoggerRepository;
use App\Service\Logger\LoggerEventListener;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Uid\Uuid;
#[ORM\EntityListeners([LoggerEventListener::class])]
#[ORM\Entity(repositoryClass: LoggerRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Logger
{
private ?EventDispatcherInterface $eventDispatcher = null;
public function setEvent(EventDispatcherInterface $eventDispatcher)
{
$this->eventDispatcher = $eventDispatcher;
}
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column]
private ?\DateTimeImmutable $entryAt = null;
#[ORM\Column(length: 255)]
private ?string $type = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;
#[ORM\ManyToOne]
private ?Account $account = null;
#[ORM\Column(length: 255)]
private ?string $hmac = null;
#[ORM\Column(type: 'uuid')]
private ?Uuid $uuid = null;
private bool $locked = false;
#[ORM\PostLoad]
public function lockAfterLoad(): void
{
$this->locked = true;
}
public function getId(): ?int
{
return $this->id;
}
public function getEntryAt(): ?\DateTimeImmutable
{
return $this->entryAt;
}
public function setEntryAt(\DateTimeImmutable $entryAt): static
{
if ($this->locked) {
$this->eventDispatcher->dispatch(new ImmutableLoggerFieldException("entryAt"));
}
$this->entryAt = $entryAt;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
if ($this->locked) {
$this->eventDispatcher->dispatch(new ImmutableLoggerFieldException("type"));
}
$this->type = $type;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
if ($this->locked) {
$this->eventDispatcher->dispatch(new ImmutableLoggerFieldException("content"));
}
$this->content = $content;
return $this;
}
public function getAccount(): ?Account
{
return $this->account;
}
public function setAccount(?Account $account): static
{
if ($this->locked) {
$this->eventDispatcher->dispatch(new ImmutableLoggerFieldException("account"));
}
$this->account = $account;
return $this;
}
public function getHmac(): ?string
{
return $this->hmac;
}
public function setHmac(string $hmac): static
{
if ($this->locked) {
$this->eventDispatcher->dispatch(new ImmutableLoggerFieldException("hmac"));
}
$this->hmac = $hmac;
return $this;
}
public function getUuid(): ?Uuid
{
return $this->uuid;
}
public function setUuid(Uuid $uuid): static
{
if ($this->locked) {
$this->eventDispatcher->dispatch(new ImmutableLoggerFieldException("uuid"));
}
$this->uuid = $uuid;
return $this;
}
public function lock(): void
{
$this->locked = true;
}
public function isLocked(): bool
{
return $this->locked;
}
}

View File

@@ -76,12 +76,17 @@ class MainframeAttributeListener
if(!$session->has('2fa_valid')) {
if(!$session->has('2fa_code')) {
$code = random_int(1000, 9999);
$session->set('2fa_code', random_int(1000, 9999));
$this->mailer->send($account->getEmail(),$account->getUsername(),"[Mainframe] - Double authentication","mails/artemis/2fa.twig",[
$session->set('2fa_code', $code);
$this->mailer->send($account->getEmail(), $account->getUsername(), "[Mainframe] - Double authentication", "mails/artemis/2fa.twig", [
'code' => $code,
'account' => $account,
]);
}
$response = new Response($this->environment->render('admin/2fa.twig', [
'account' => $account,
]));
$event->setResponse($response);
$event->stopPropagation();
if($request->isMethod('POST')) {
$code = $request->request->get('code');
if((int)$code == $session->get('2fa_code')) {
@@ -94,6 +99,7 @@ class MainframeAttributeListener
$this->entityManager->flush();
$session->remove('2fa_code');
$session->set('2fa_valid',true);
$request->setSession($session);
$redirect = new RedirectResponse("/artemis");
$redirect->setStatusCode(302);
$event->setResponse($redirect);
@@ -103,14 +109,10 @@ class MainframeAttributeListener
'account' => $account,
'error' => 'Code non valide !'
]));
$event->setResponse($response);
$event->stopPropagation();
}
} else {
$response = new Response($this->environment->render('admin/2fa.twig', [
'account' => $account,
]));
}
$event->setResponse($response);
$event->stopPropagation();
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class ImmutableLoggerFieldException extends \LogicException
{
public function __construct(string $field)
{
parent::__construct(sprintf("The '%s' field is immutable once set.", $field));
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Form\Artemis\Admin;
use App\Entity\Account;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AdminFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('username',TextType::class,[
'label' => 'Nom d\'utilisateur',
'required' => true
])
->add('email',EmailType::class,[
'label' => 'Email',
'required' => true
])
->add('isActif',ChoiceType::class,[
'label' => 'Compte Actif',
'choices' =>[
'Désactiver' => false,
'Actif' => true,
]
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class',Account::class);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Form\Artemis\Admin;
use App\Entity\Account;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AdminPasswordType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('password',RepeatedType::class,[
'first_options' =>[
'label' => 'Mot de passe'
],
'second_options' => [
'label' => 'Confirmée le mot de passe'
],
'type' => PasswordType::class,
'required' => true,
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class',Account::class);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Entity\Account;
use App\Entity\AccountLoginRegister;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -40,4 +41,12 @@ class AccountLoginRegisterRepository extends ServiceEntityRepository
// ->getOneOrNullResult()
// ;
// }
public function lastLogin(Account $account)
{
return $this->createQueryBuilder('a')
->andWhere('a.account = :acc')
->setParameter('acc',$account)
->orderBy('a.loginAt','DESC')
->getQuery()->getResult();
}
}

View File

@@ -32,4 +32,6 @@ class AccountRepository extends ServiceEntityRepository implements PasswordUpgra
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}

View File

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

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Security;
use App\Entity\Account;
use App\Entity\User as AppUser;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\AccountExpiredException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class UserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user): void
{
if(!$user instanceof Account) {
return;
}
if(!$user->isActif()) {
throw new CustomUserMessageAccountStatusException('Votre compte à été désactivée');
}
}
public function checkPostAuth(UserInterface $user): void
{
}
}

View File

@@ -30,7 +30,7 @@ class ComputeEngineClient
$this->projectId = $content->project_id;
$this->zone = "europe-west4-a";
}
public function list()
public function list(): array
{
$request = (new ListInstancesRequest())
->setProject($this->projectId)

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Service\Logger;
use App\Entity\Logger;
use App\Exception\ImmutableLoggerFieldException;
use App\Service\Mailer\Mailer;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class LoggerEventListener
{
private LoggerInterface $logger;
public function __construct(private Mailer $mailer,private readonly TokenStorageInterface $tokenStorage,LoggerInterface $logger)
{
$this->logger = $logger;
}
public function postPersist(Logger $logger, LifecycleEventArgs $args): void
{
try {
$logger->lock();
} catch (ImmutableLoggerFieldException $e) {
$this->logger->warning('Tentative de verrouillage d\'une entité Logger déjà verrouillée (postPersist) : ' . $e->getMessage());
}
}
public function postLoad(Logger $logger, LifecycleEventArgs $args): void
{
try {
$logger->lock();
} catch (ImmutableLoggerFieldException $e) {
$this->logger->warning('Tentative de verrouillage d\'une entité Logger déjà verrouillée (postLoad) : ' . $e->getMessage());
}
}
#[AsEventListener(event: ImmutableLoggerFieldException::class)]
public function onKernelException(ImmutableLoggerFieldException $event): void {
$account = $this->tokenStorage->getToken();
$account = $account->getUser();
$this->mailer->sendMulti(["jovann@siteconseil.fr","legrand@siteconseil.fr"],"[Mainframe] - Tentative de modifier du log ! ","mails/artemis/error-logger.twig",[
'account' => $account,
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Service\Logger;
use App\Entity\Account;
use App\Entity\Logger;
use App\Service\Vault\VaultClient;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class LoggerService
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly VaultClient $vaultClient,
) {}
public function log(string $type, string $content, ?Account $account = null): Logger
{
$entryAt = new \DateTimeImmutable();
$uuid = Uuid::v4();
$logger = new Logger();
$logger->setEvent($this->eventDispatcher);
$logger->setEntryAt($entryAt)
->setType($this->vaultClient->encrypt("mainframe_logger",$type))
->setContent($this->vaultClient->encrypt("mainframe_logger",$content))
->setAccount($account)
->setUuid($uuid);
$hmac = $this->generateHmac($logger);
$logger->setHmac($this->vaultClient->encrypt("mainframe_logger",$hmac));
$this->em->persist($logger);
$this->em->flush();
return $logger;
}
public function isLoggerTampered(Logger $logger): bool
{
$expectedHmac = $this->generateHmac($logger);
return !hash_equals($expectedHmac, $logger->getHmac());
}
private function generateHmac(Logger $logger): string
{
$secretKey = $_ENV['APP_SECRET'] ?? 'change_this_secret';
$data = implode('|', [
$logger->getType(),
$logger->getContent(),
$logger->getEntryAt()?->format(DATE_ATOM),
$logger->getAccount()?->getId(),
$logger->getUuid()?->toRfc4122(),
]);
return hash_hmac('sha256', $data, $secretKey);
}
public function load(Account $account)
{
return $this->em->getRepository(Logger::class)->load($account);
}
}

View File

@@ -116,7 +116,6 @@ class Mailer
$this->mailer->send($mail);
$object->setStatus("sent");
} catch (TransportExceptionInterface $e) {
dd($e);
$object->setStatus("error");
}
$this->entityManager->persist($object);
@@ -139,4 +138,42 @@ class Mailer
];
}
public function sendMulti(array $addressList, string $subject, string $template, array $data)
{
$src = new Address("mainframe@esy-web.dev", "Mainframe EsyWeb");
$mail = new Email();
$mail->subject($subject);
foreach ($addressList as $address) {
$dest = new Address($address);
$mail->addTo($dest);
}
$mail->from($src);
$messageId = $mail->generateMessageId();
$header = $mail->getHeaders();
$header->add(new IdentificationHeader("Message-Id",$messageId));
$datasSign = $this->generateTracking($mail);
/** @var Mail $object */
$object = $datasSign['object'];
$mjmlGenerator = $this->environment->render($template, [
'system' => [
'subject' => $subject,
'tracking_url'=>$datasSign['url']
],
'datas' => $data,
]);
$htmlContent = $this->convertMjmlToHtml($mjmlGenerator);
$object->setContent($htmlContent);
$mail->html($htmlContent);
try {
$this->mailer->send($mail);
$object->setStatus("sent");
} catch (TransportExceptionInterface $e) {
$object->setStatus("error");
}
$this->entityManager->persist($object);
$this->entityManager->flush();
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Service\Vault;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
class VaultClient
{
private string $vaultAddr;
private string $vaultToken;
private const KEYS = [
'mainframe_logger',
];
public function __construct(private readonly HttpClientInterface $httpClient)
{
$this->vaultAddr = rtrim($_ENV['VAULT_ADDR'], '/');
$this->vaultToken = $_ENV['VAULT_TOKEN'];
$this->ensureTransitEnabled();
$this->ensureKeysExist();
}
public function encrypt(string $key, string $content): ?string
{
$url = sprintf('%s/v1/transit/encrypt/%s', $this->vaultAddr, $key);
try {
$response = $this->httpClient->request('POST', $url, [
'headers' => [
'X-Vault-Token' => $this->vaultToken,
],
'json' => [
'plaintext' => base64_encode($content),
],
]);
$data = $response->toArray(false);
return $data['data']['ciphertext'] ?? null;
} catch (TransportExceptionInterface $e) {
return null;
}
}
public function decrypt(string $key, string $encrypted): ?string
{
$url = sprintf('%s/v1/transit/decrypt/%s', $this->vaultAddr, $key);
try {
$response = $this->httpClient->request('POST', $url, [
'headers' => [
'X-Vault-Token' => $this->vaultToken,
],
'json' => [
'ciphertext' => $encrypted,
],
]);
$data = $response->toArray(false);
return isset($data['data']['plaintext']) ? base64_decode($data['data']['plaintext']) : null;
} catch (TransportExceptionInterface $e) {
return null;
}
}
public function ensureTransitEnabled(): bool
{
$url = sprintf('%s/v1/sys/mounts', $this->vaultAddr);
try {
$response = $this->httpClient->request('GET', $url, [
'headers' => [
'X-Vault-Token' => $this->vaultToken,
],
]);
$data = $response->toArray(false);
if (!isset($data['transit/'])) {
$enableUrl = sprintf('%s/v1/sys/mounts/transit', $this->vaultAddr);
$this->httpClient->request('POST', $enableUrl, [
'headers' => [
'X-Vault-Token' => $this->vaultToken,
],
'json' => [
'type' => 'transit',
],
]);
}
return true;
} catch (TransportExceptionInterface $e) {
return false;
}
}
private function ensureKeysExist(): void
{
foreach (self::KEYS as $key) {
$this->createKeyIfNotExists($key);
}
}
public function createKeyIfNotExists(string $key): void
{
$url = sprintf('%s/v1/transit/keys/%s', $this->vaultAddr, $key);
try {
$this->httpClient->request('GET', $url, [
'headers' => [
'X-Vault-Token' => $this->vaultToken,
],
]);
} catch (\Exception $exception) {
$this->httpClient->request('POST', $url, [
'headers' => [
'X-Vault-Token' => $this->vaultToken,
],
'json' => [
'type' => 'aes256-gcm96',
],
]);
}
}
public function deleteKey(string $key): bool
{
$url = sprintf('%s/v1/transit/keys/%s', $this->vaultAddr, $key);
try {
$this->httpClient->request('DELETE', $url, [
'headers' => [
'X-Vault-Token' => $this->vaultToken,
],
]);
return true;
} catch (TransportExceptionInterface $e) {
return false;
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Twig;
use App\Entity\Logger;
use App\Service\Logger\LoggerService;
use App\Service\Vault\VaultClient;
use AWS\CRT\Log;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class VaultExtensions extends AbstractExtension
{
public function __construct(private readonly VaultClient $vaultClient,private readonly LoggerService $loggerService)
{
}
public function getFilters()
{
return [
new TwigFilter('decrypt',[$this,'decrypt']),
new TwigFilter('verifyLogger',[$this,'verifyLogger'])
];
}
public function verifyLogger(Logger $logger)
{
return $logger->isLocked();
}
public function decrypt(string $content,string $key)
{
return $this->vaultClient->decrypt($key,$content);
}
}

View File

@@ -97,6 +97,25 @@
</li>
</ul>
</li>
{% if is_granted('ROLE_ADMIN') %}
<li class="px-4 py-2">
<button class="flex items-center justify-between w-full p-2 text-base font-normal text-gray-900 dark:text-white rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none" data-submenu-toggle="settings">
<div class="flex items-center">
<i class="fad fa-cogs"></i>
<span class="ml-3">Paramétres</span>
</div>
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400 arrow-icon" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
</button>
<ul id="submenu-settings" class="submenu ml-6 mt-2 space-y-2">
<li>
<a href="{{ path('artemis_settings_accountAdmin') }}" class="flex items-center p-2 text-sm font-normal text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<i class="fad fa-users"></i>
<span class="ml-3">Administrateur</span>
</a>
</li>
</ul>
</li>
{% endif %}
</ul>
</nav>
</aside>

View File

@@ -1,12 +1,14 @@
{% extends 'artemis/base.twig' %}
{% block title %}Infrastructure - Serveur{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-semibold text-gray-800 dark:text-gray-200">Liste des Serveurs</h2>
<div>
<button class="px-4 py-2 bg-blue-600 text-white font-medium rounded-md shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
<a h class="px-4 py-2 bg-blue-600 text-white font-medium rounded-md shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
+ Ajouter un nouveau serveur
</button>
</a>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">

View File

@@ -0,0 +1,55 @@
{% extends 'artemis/base.twig' %}
{% block title %}Compte Administrateur{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-semibold text-gray-800 dark:text-gray-200">Compte administrateur</h2>
<div>
<a href="" class="px-4 py-2 bg-blue-600 text-white font-medium rounded-md shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
+ Crée un compte administrateur
</a>
</div>
</div>
<div class="bg-gray-800 shadow-md rounded-lg overflow-hidden">
<table class="min-w-full table-auto">
<thead class="bg-gray-700 text-white">
<tr>
<th class="px-4 py-2 text-left">Nom d'utilisateur</th>
<th class="px-4 py-2 text-left">Email</th>
<th class="px-4 py-2 text-left">Rôle</th>
<th class="px-4 py-2 text-left">Statut</th>
<th class="px-4 py-2 text-left">Dernière connexion</th>
<th class="px-4 py-2 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-600">
{% for account in admins %}
<tr class="hover:bg-gray-700">
<td class="px-4 py-2">{{ account.username }}</td>
<td class="px-4 py-2">{{ account.email }}</td>
<td class="px-4 py-2">
<span class="inline-block bg-green-600 text-white text-xs font-semibold px-2 py-1 rounded-full">{{ account.roles[0]|trans }}</span>
</td>
{% if account.isActif %}
<td class="px-4 py-2 text-green-400 font-medium">Actif</td>
{% else %}
<td class="px-4 py-2 text-red-400 font-medium">Désactivée</td>
{% endif %}
<td class="px-4 py-2">{{ account.lastLoginAt.loginAt|date('d/m/Y H:i:s') }}</td>
<td class="px-4 py-2">
<a href="{{ path('artemis_settings_accountAdmin_view',{id:account.id}) }}" class="px-4 py-2 bg-blue-600 text-white font-medium rounded-md shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
<i class="fad fa-pencil"></i>
</a>
{% if account.email != "jovann@siteconseil.fr" and account.email != "legrand@siteconseil.fr" %}
<a href="" class="ml-2 px-4 py-2 bg-red-600 text-white font-medium rounded-md shadow-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
<i class="fad fa-trash"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,115 @@
<div class="w-full mx-auto px-4 py-10">
<h1 class="text-3xl font-bold mb-6 text-white">Historique des connexions</h1>
<div class="bg-gray-800 rounded-lg shadow divide-y divide-gray-700">
{% for log in logs %}
<div class="flex items-center justify-between px-6 py-4 hover:bg-gray-700 transition">
<div>
<p class="text-sm text-gray-400">Date :
<span class="text-white font-medium">
{{ log.loginAt|date('d/m/Y H:i') }}
</span>
</p>
<p class="text-sm text-gray-400">IP :
<span class="text-white font-medium">
{{ log.ip ?? 'Adresse inconnue' }}
</span>
</p>
<p class="text-sm text-gray-400 flex items-center gap-2">
Appareil :
{% set ua = log.userAgent|default('') %}
<span class="text-white font-medium flex items-center gap-2">
{% if 'Chrome' in ua and 'Edg' not in ua and 'OPR' not in ua %}
<!-- Chrome SVG -->
<svg class="w-5 h-5" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="256" fill="#4285F4"/>
<path d="M256 0c63.65 0 121.22 26.13 161.15 68.12L349.53 185.7l-84.04 145.12L256 512c-63.66 0-121.23-26.13-161.16-68.12L162.48 326.3l84.04-145.12L256 0z" fill="#34A853"/>
<circle cx="256" cy="256" r="96" fill="#FBBC05"/>
<circle cx="256" cy="256" r="64" fill="white"/>
</svg>
{% elseif 'Firefox' in ua %}
<!-- Firefox SVG -->
<svg class="w-5 h-5" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="256" fill="#FF7139"/>
<path fill="#E66000" d="M128 128L384 384"/>
<circle cx="256" cy="256" r="120" fill="#F8B500"/>
<path fill="#FF6F00" d="M256 136a120 120 0 1 0 0 240 120 120 0 0 0 0-240z"/>
</svg>
{% elseif 'Safari' in ua and 'Chrome' not in ua %}
<!-- Safari SVG -->
<svg class="w-5 h-5" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="256" fill="#0CA3E7"/>
<circle cx="256" cy="256" r="192" fill="white"/>
<path d="M256 96v320M160 256h192" stroke="#0CA3E7" stroke-width="20" stroke-linecap="round"/>
</svg>
{% elseif 'Edg' in ua %}
<!-- Edge SVG -->
<svg class="w-5 h-5" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="256" fill="#0078D7"/>
<path fill="#00BCF2" d="M256 128c-70 0-127 57-127 127h254c0-70-57-127-127-127z"/>
<circle cx="256" cy="256" r="60" fill="#004C87"/>
</svg>
{% else %}
<!-- Unknown SVG -->
<svg class="w-5 h-5" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="32" r="30" stroke="gray" stroke-width="4"/>
<text x="32" y="38" text-anchor="middle" fill="gray" font-size="24" font-family="Arial" font-weight="bold">?</text>
</svg>
{% endif %}
{{ ua ?: 'Non détecté' }}
</span>
</p>
</div>
<div>
{% if loop.first %}
<span class="text-xs bg-green-600 text-white px-2 py-1 rounded">Connexion actuelle</span>
{% else %}
<span class="text-xs bg-gray-600 text-white px-2 py-1 rounded">Connexion antérieure</span>
{% endif %}
</div>
</div>
{% else %}
<div class="px-6 py-4 text-gray-400">Aucune connexion enregistrée.</div>
{% endfor %}
</div>
<h1 class="mt-5 text-3xl font-bold mb-6 text-white">Actions du compte</h1>
<div class="bg-gray-800 rounded-lg shadow divide-y divide-gray-700">
{% for action in actions %}
<div class="flex items-center justify-between px-6 py-4 hover:bg-gray-700 transition">
<div>
<p class="text-sm text-gray-400">Date :
<span class="text-white font-medium">
{{ action.entryAt|date('d/m/Y H:i') }}
</span>
</p>
<p class="text-sm text-gray-400">IP :
<span class="text-white font-medium">
{{ action.ip ?? 'Adresse inconnue' }}
</span>
</p>
<p class="text-sm text-gray-400">Details :
<span class="text-white font-medium">
{{ action.type|decrypt('mainframe_logger') }} - {{ action.content|decrypt('mainframe_logger') }}
</span>
</p>
<p class="text-sm text-gray-400">Signature HMAC :
<span class="text-white font-medium">
{{ action.hmac|decrypt('mainframe_logger') }} - {{ action.uuid }}
</span>
</p>
<p class="text-sm text-gray-400">Vérification :
{% if action|verifyLogger %}
<i class="fad fa-check-circle text-green-400"></i>
{% else %}
<i class="fad fa-times-circle text-red-400"></i>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{{ knp_pagination_render(actions, 'pagination/tailwind_pagination.html.twig') }}
</div>
</div>

View File

@@ -0,0 +1,49 @@
<!-- Contenu Global avec deux cartes -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<!-- Informations du compte -->
<div class="bg-gray-800 rounded-lg shadow-lg p-6 space-y-4">
<div class="flex items-center justify-center mb-4">
{% if account.avatarOriginalName is defined and account.avatarOriginalName is not empty %}
<img src="{{ vich_uploader_asset(account,'avatar') | imagine_filter('webp') }}" alt="Avatar de {{ account.username }}" class="w-36 h-36 rounded-full border-4 border-gray-700">
{% else %}
<img src="{{ path('artemis_avatar') }}" class="w-36 h-36 rounded-full border-4 border-gray-700" alt="Avatar de {{ account.username }}">
{% endif %}
</div>
<div>
<h2 class="text-xl font-semibold">Nom d'utilisateur</h2>
<p class="text-gray-300">{{ account.username }}</p>
</div>
<div>
<h2 class="text-xl font-semibold">Email</h2>
<p class="text-gray-300">{{ account.email }}</p>
</div>
<div>
<h2 class="text-xl font-semibold">Rôle</h2>
<span class="inline-block bg-purple-600 text-white text-sm px-3 py-1 rounded-full">{{ account.roles[0]|trans }}</span>
</div>
<div>
<h2 class="text-xl font-semibold">Statut</h2>
<span class="{% if account.isActif %}text-green-400{% else %}text-red-400{% endif %} font-medium">{% if account.isActif %}Actif{% else %}Désactivé{% endif %}</span>
</div>
<div>
<h2 class="text-xl font-semibold">Dernière connexion</h2>
<p class="text-gray-300">{{ account.lastLoginAt.loginAt|date('d/m/Y H:i:s') }}</p>
</div>
</div>
<!-- Formulaire de modification -->
<div class="md:col-span-3 bg-gray-800 rounded-lg shadow-lg p-6">
{{ form_start(formAccount) }}
{{ form_row(formAccount.username) }}
{{ form_row(formAccount.email) }}
{{ form_row(formAccount.isActif) }}
<button type="submit" class="w-full bg-purple-600 hover:bg-purple-700 text-white font-semibold px-4 py-2 rounded">Enregistrer</button>
{{ form_end(formAccount) }}
</div>
</div>

View File

@@ -0,0 +1,6 @@
<div class="bg-gray-800 rounded-lg shadow-lg p-6">
{{ form_start(formPassword) }}
{{ form_row(formPassword.password) }}
<button type="submit" class="w-full bg-purple-600 hover:bg-purple-700 text-white font-semibold px-4 py-2 rounded">Enregistrer</button>
{{ form_end(formPassword) }}
</div>

View File

@@ -0,0 +1,35 @@
{% extends 'artemis/base.twig' %}
{% block title %}
Compte Administrateur - {{ account.username }}
{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-semibold text-gray-800 dark:text-gray-200">Compte administrateurs - {{ account.username }}</h2>
</div>
<div class="flex space-x-4 mb-6 border-b border-gray-700">
{% set active = "text-sm border-b-2 border-purple-500" %}
{% set desactive = "text-sm text-gray-400 hover:text-white" %}
<a href="{{ path('artemis_settings_accountAdmin_view',{id:account.id,type:'main'}) }}" class="px-4 py-2 font-semibold {% if current == "main" %}{{ active }}{% else %}{{ desactive }}{% endif %}">
<i class="fad fa-home"></i>
Global
</a>
<a href="{{ path('artemis_settings_accountAdmin_view',{id:account.id,type:'security'}) }}" class="px-4 py-2 font-semibold {% if current == "security" %}{{ active }}{% else %}{{ desactive }}{% endif %}">
<i class="fad fa-lock"></i>
Sécurité
</a>
<a href="{{ path('artemis_settings_accountAdmin_view',{id:account.id,type:'permissions'}) }}" class="px-4 py-2 font-semibold {% if current == "permissions" %}{{ active }}{% else %}{{ desactive }}{% endif %}">
<i class="fad fa-key"></i>
Permissions
</a>
<a href="{{ path('artemis_settings_accountAdmin_view',{id:account.id,type:'logs'}) }}" class="px-4 py-2 font-semibold {% if current == "logs" %}{{ active }}{% else %}{{ desactive }}{% endif %}">
<i class="fad fa-book"></i>
Logs
</a>
</div>
{% include 'artemis/settings/account/'~current~'.twig' %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-text>Bonjour,</mj-text>
<mj-text>
Une tentative de modification de données verrouillées a été détectée action effectuée par {{ datas.account.username }}.
</mj-text>
<mj-text>
Les champs concernés sont protégés et ne peuvent être modifiés après enregistrement initial. Cette tentative a été automatiquement bloquée par notre système de sécurité.
</mj-text>
<mj-text>
Si vous êtes à l'origine de cette action et pensez qu'il s'agit d'une erreur, vous pouvez contacter le support pour plus d'informations.
</mj-text>
<mj-text>
Si vous n'êtes pas à l'origine de cette tentative, aucune action supplémentaire n'est requise. Vos données restent protégées.
</mj-text>
<mj-text padding-top="20px">Cordialement,</mj-text>
<mj-text>L'&eacute;quipe Mainframe</mj-text>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{# tailwindcss Sliding pagination control implementation with dark mode and full width #}
{% if pageCount > 1 %}
<div class="w-full">
<div class="flex items-baseline flex-row border rounded-sm w-full
border-gray-400 bg-white dark:bg-gray-800 dark:border-gray-600
justify-center">
{% if first is defined and current != first %}
<span class="px-3 py-2 text-lg border-r font-bold
border-gray-400 text-blue-600 bg-white hover:bg-gray-100
dark:border-gray-600 dark:text-blue-400 dark:bg-gray-800 dark:hover:bg-gray-700">
<a href="{{ path(route, knp_pagination_query(query, first, options)) }}">&lt;&lt;</a>
</span>
{% endif %}
{% if previous is defined %}
<span class="px-3 py-2 text-lg border-r
border-gray-400 text-blue-600 bg-white hover:bg-gray-100
dark:border-gray-600 dark:text-blue-400 dark:bg-gray-800 dark:hover:bg-gray-700">
<a rel="prev" href="{{ path(route, knp_pagination_query(query, previous, options)) }}">&lt;</a>
</span>
{% endif %}
{% for page in pagesInRange %}
{% if page != current %}
<span class="px-3 py-2 text-lg border-r
border-gray-400 text-blue-600 bg-white hover:bg-gray-100
dark:border-gray-600 dark:text-blue-400 dark:bg-gray-800 dark:hover:bg-gray-700">
<a href="{{ path(route, knp_pagination_query(query, page, options)) }}">{{ page }}</a>
</span>
{% else %}
<span class="px-3 py-2 text-lg font-bold
bg-blue-600 text-white
dark:bg-blue-500 dark:text-gray-900">
{{ page }}
</span>
{% endif %}
{% endfor %}
{% if next is defined %}
<span class="px-3 py-2 text-lg border-r
border-gray-400 text-blue-600 bg-white hover:bg-gray-100
dark:border-gray-600 dark:text-blue-400 dark:bg-gray-800 dark:hover:bg-gray-700">
<a rel="next" href="{{ path(route, knp_pagination_query(query, next, options)) }}">&gt;</a>
</span>
{% endif %}
{% if last is defined and current != last %}
<span class="px-3 py-2 text-lg font-bold
border-gray-400 text-blue-600 bg-white hover:bg-gray-100
dark:border-gray-600 dark:text-blue-400 dark:bg-gray-800 dark:hover:bg-gray-700">
<a href="{{ path(route, knp_pagination_query(query, last, options)) }}">&gt;&gt;</a>
</span>
{% endif %}
</div>
</div>
{% endif %}