```
✨ feat(crm/admin): Améliore gestion des administrateurs et sécurité
Ajoute formulaires identité et mot de passe, rôles dynamiques.
Gère statuts, journal d'audit, connexions.
Améliore les notifications.
```
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import './app.scss'
|
import './app.scss'
|
||||||
import * as Turbo from "@hotwired/turbo"
|
import * as Turbo from "@hotwired/turbo"
|
||||||
|
|
||||||
import * as Turbo from "@hotwired/turbo";
|
|
||||||
|
|
||||||
// --- INITIALISATION SENTRY (En premier !) ---
|
// --- INITIALISATION SENTRY (En premier !) ---
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ security:
|
|||||||
App\Entity\Account: 'auto'
|
App\Entity\Account: 'auto'
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
ROLE_ROOT: [ROLE_ADMIN]
|
ROLE_ROOT: [ROLE_ADMIN,ROLE_CLIENT_MAIN]
|
||||||
|
ROLE_CLIENT_MAIN: [ROLE_ADMIN]
|
||||||
|
|
||||||
|
|
||||||
access_control:
|
access_control:
|
||||||
# Permettre l'accès aux pages 2FA même si on n'est pas encore pleinement "ROLE_ADMIN"
|
# Permettre l'accès aux pages 2FA même si on n'est pas encore pleinement "ROLE_ADMIN"
|
||||||
|
|||||||
32
migrations/Version20260115194430.php
Normal file
32
migrations/Version20260115194430.php
Normal 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 Version20260115194430 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 audit_log ADD hash_code VARCHAR(64) NOT 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 audit_log DROP hash_code');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,15 @@ namespace App\Controller\Dashboard;
|
|||||||
use App\Entity\Account;
|
use App\Entity\Account;
|
||||||
use App\Event\Object\EventAdminCreate;
|
use App\Event\Object\EventAdminCreate;
|
||||||
use App\Event\Object\EventAdminDeleted;
|
use App\Event\Object\EventAdminDeleted;
|
||||||
|
use App\Form\AccountPasswordType;
|
||||||
use App\Form\AccountType;
|
use App\Form\AccountType;
|
||||||
use App\Logger\AppLogger;
|
use App\Logger\AppLogger;
|
||||||
|
use App\Repository\AccountLoginRegisterRepository;
|
||||||
use App\Repository\AccountRepository;
|
use App\Repository\AccountRepository;
|
||||||
|
use App\Repository\AuditLogRepository;
|
||||||
|
use App\Service\Mailer\Mailer;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Knp\Component\Pager\PaginatorInterface;
|
||||||
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\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -23,10 +28,10 @@ class AccountController extends AbstractController
|
|||||||
* Liste des administrateurs
|
* Liste des administrateurs
|
||||||
*/
|
*/
|
||||||
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET'])]
|
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET'])]
|
||||||
public function administrateur(AccountRepository $accountRepository, AppLogger $appLogger,EventDispatcherInterface $eventDispatcher): Response
|
public function administrateur(AccountRepository $accountRepository, AppLogger $appLogger): Response
|
||||||
{
|
{
|
||||||
// Audit Log : On trace la consultation de la liste
|
$appLogger->record('VIEW', 'Consultation de la liste des Administrateurs');
|
||||||
$appLogger->record('VIEW', 'Consultation de la liste des administrateurs');
|
|
||||||
return $this->render('dashboard/administrateur.twig', [
|
return $this->render('dashboard/administrateur.twig', [
|
||||||
'admins' => $accountRepository->findAdmin(),
|
'admins' => $accountRepository->findAdmin(),
|
||||||
]);
|
]);
|
||||||
@@ -42,10 +47,9 @@ class AccountController extends AbstractController
|
|||||||
UserPasswordHasherInterface $passwordHasher,
|
UserPasswordHasherInterface $passwordHasher,
|
||||||
AppLogger $appLogger,
|
AppLogger $appLogger,
|
||||||
EventDispatcherInterface $eventDispatcher
|
EventDispatcherInterface $eventDispatcher
|
||||||
): Response {
|
): 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->setUuid(Uuid::v4()->toRfc4122());
|
||||||
@@ -55,63 +59,196 @@ class AccountController extends AbstractController
|
|||||||
$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));
|
$tempPassword = bin2hex(random_bytes(20));
|
||||||
$hashedPassword = $passwordHasher->hashPassword($account, $tempPassword);
|
$account->setPassword($passwordHasher->hashPassword($account, $tempPassword));
|
||||||
$account->setPassword($hashedPassword);
|
|
||||||
|
|
||||||
$entityManager->persist($account);
|
$entityManager->persist($account);
|
||||||
$entityManager->flush();
|
$entityManager->flush();
|
||||||
|
|
||||||
// Audit Log : Enregistrement de la création
|
|
||||||
$appLogger->record(
|
$appLogger->record(
|
||||||
'CREATE',
|
'CREATE',
|
||||||
sprintf("Création de l'administrateur : %s %s (%s)",
|
sprintf("Création de l'Administrateur : %s %s (%s)",
|
||||||
$account->getFirstName(),
|
$account->getFirstName(), $account->getName(), $account->getEmail()
|
||||||
$account->getName(),
|
|
||||||
$account->getEmail()
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Notification : Envoi du mail d'activation
|
|
||||||
$eventDispatcher->dispatch(new EventAdminCreate($account, $this->getUser()));
|
$eventDispatcher->dispatch(new EventAdminCreate($account, $this->getUser()));
|
||||||
|
$this->addFlash('success', "L'Administrateur " . $account->getFirstName() . " a été créé avec succès.");
|
||||||
$this->addFlash('success', 'Le compte administrateur de ' . $account->getFirstName() . ' a été créé avec succès.');
|
|
||||||
|
|
||||||
return $this->redirectToRoute('app_crm_administrateur');
|
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'])]
|
||||||
* Voir la fiche d'un administrateur
|
public function administrateurView(
|
||||||
*/
|
?Account $account,
|
||||||
#[Route(path: '/crm/administrateur/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET'])]
|
PaginatorInterface $paginator,
|
||||||
public function administrateurView(?Account $account, AppLogger $appLogger): Response
|
Request $request,
|
||||||
{
|
AppLogger $appLogger,
|
||||||
|
Mailer $mailer,
|
||||||
|
AccountLoginRegisterRepository $accountLoginRegisterRepository,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
AuditLogRepository $auditLogRepository,
|
||||||
|
UserPasswordHasherInterface $passwordHasher
|
||||||
|
): Response {
|
||||||
if (!$account) {
|
if (!$account) {
|
||||||
$this->addFlash('error', 'Administrateur introuvable.');
|
$this->addFlash('error', 'Administrateur introuvable.');
|
||||||
return $this->redirectToRoute('app_crm_administrateur');
|
return $this->redirectToRoute('app_crm_administrateur');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit Log : On trace la consultation d'une fiche spécifique
|
// --- SÉCURITÉ : PROTECTION ROOT ---
|
||||||
$appLogger->record(
|
if (in_array('ROLE_ROOT', $account->getRoles()) && !$this->isGranted('ROLE_ROOT')) {
|
||||||
'VIEW',
|
$title = "Tentative d'accès non autorisée";
|
||||||
sprintf("Consultation du profil de : %s %s (ID: %d)",
|
$details = sprintf("L'utilisateur %s a tenté de consulter l'Administrateur ROOT protégé : %s %s (ID: %d)",
|
||||||
$account->getFirstName(),
|
$this->getUser()->getUserIdentifier(), $account->getFirstName(), $account->getName(), $account->getId()
|
||||||
$account->getName(),
|
);
|
||||||
$account->getId()
|
$appLogger->record('SECURITY_ALERT', $details);
|
||||||
)
|
// $this->sendSecurityEmail($mailer, $title, $details); // Activez si la méthode existe
|
||||||
|
|
||||||
|
$this->addFlash('error', 'Sécurité : Le compte est protégé');
|
||||||
|
return $this->redirectToRoute('app_crm_administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. LOGIQUE DE MISE À JOUR DU STATUT (Lien Direct) ---
|
||||||
|
// --- LOGIQUE DE MISE À JOUR DU STATUT (Lien Direct) ---
|
||||||
|
// --- LOGIQUE DE MISE À JOUR DU STATUT (Lien Direct) ---
|
||||||
|
if ($request->query->get('act') === 'updateStatut') {
|
||||||
|
$statusParam = $request->query->get('status');
|
||||||
|
$newStatus = ($statusParam === 'true');
|
||||||
|
|
||||||
|
// On définit un sujet dynamique pour plus de clarté
|
||||||
|
$subject = $newStatus
|
||||||
|
? "[Intranet Ludikevent] - Activation de votre accès"
|
||||||
|
: "[Intranet Ludikevent] - Suspension de votre accès";
|
||||||
|
|
||||||
|
// Envoi du mail de notification
|
||||||
|
$mailer->send(
|
||||||
|
$account->getEmail(),
|
||||||
|
$account->getFirstName() . " " . $account->getName(),
|
||||||
|
$subject,
|
||||||
|
"mails/account/status-update.twig",
|
||||||
|
[
|
||||||
|
'who' => $this->getUser(),
|
||||||
|
'account' => $account,
|
||||||
|
'stats' => $newStatus,
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mise à jour en base de données
|
||||||
|
// Vérifiez si votre entité utilise setActif() ou setIsActif()
|
||||||
|
$account->setIsActif($newStatus);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$label = $newStatus ? 'activé' : 'désactivé';
|
||||||
|
|
||||||
|
// Log et Message Flash
|
||||||
|
$appLogger->record('UPDATE_STATUS', sprintf("%s du compte pour l'admin %d", ucfirst($label), $account->getId()));
|
||||||
|
$this->addFlash('success', "Le compte de " . $account->getFirstName() . " a été $label avec succès.");
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. LOGIQUE DE MISE À JOUR DES RÔLES (POST) ---
|
||||||
|
if ($request->isMethod('POST') && $request->query->get('act') === 'updateRoles') {
|
||||||
|
$submittedRoles = $request->request->all('roles');
|
||||||
|
$mandatoryRoles = ['ROLE_USER', 'ROLE_ADMIN'];
|
||||||
|
|
||||||
|
foreach ($mandatoryRoles as $role) {
|
||||||
|
if (!in_array($role, $submittedRoles)) {
|
||||||
|
$submittedRoles[] = $role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('ROLE_CLIENT_MAIN', $submittedRoles) && !$this->isGranted('ROLE_ROOT')) {
|
||||||
|
$this->addFlash('error', 'Action non autorisée : Privilège ROOT requis.');
|
||||||
|
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$account->setRoles($submittedRoles);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$appLogger->record('UPDATE_ROLES', sprintf("Mise à jour des rôles pour l'admin %d", $account->getId()));
|
||||||
|
$this->addFlash('success', 'Les permissions ont été mises à jour.');
|
||||||
|
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. LOGIQUE CHANGEMENT DE MOT DE PASSE ---
|
||||||
|
$passwordForm = $this->createForm(AccountPasswordType::class, $account);
|
||||||
|
$passwordForm->handleRequest($request);
|
||||||
|
|
||||||
|
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
|
||||||
|
// 1. On récupère le mot de passe en clair
|
||||||
|
$plainPassword = $passwordForm->get('password')->getData();
|
||||||
|
|
||||||
|
// 2. On hache pour la base de données
|
||||||
|
$hashedPassword = $passwordHasher->hashPassword($account, $plainPassword);
|
||||||
|
$account->setPassword($hashedPassword);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
// 3. Envoi du mail avec les bons paramètres
|
||||||
|
$mailer->send(
|
||||||
|
$account->getEmail(),
|
||||||
|
$account->getName() . " " . $account->getFirstName(),
|
||||||
|
"[Intranet Ludikevent] - Modification de votre mot de passe",
|
||||||
|
"mails/account/password-modify.twig", // Correction orthographe 'account'
|
||||||
|
[
|
||||||
|
'who' => $this->getUser(),
|
||||||
|
'account' => $account,
|
||||||
|
'password' => $plainPassword // On envoie le mot de passe en clair ici
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
$appLogger->record('UPDATE_PASSWORD', sprintf("Mot de passe modifié et envoyé pour l'admin %d", $account->getId()));
|
||||||
|
$this->addFlash('success', 'Le mot de passe a été mis à jour et envoyé à l\'utilisateur.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. LOGIQUE DU FORMULAIRE D'IDENTITÉ ---
|
||||||
|
$form = $this->createForm(AccountType::class, $account);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$em->flush();
|
||||||
|
$appLogger->record('UPDATE_IDENTITY', sprintf("Mise à jour identité : %s %s", $account->getFirstName(), $account->getName()));
|
||||||
|
$this->addFlash('success', 'Informations personnelles mises à jour.');
|
||||||
|
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. AFFICHAGE & PAGINATION ---
|
||||||
|
$appLogger->record('VIEW', sprintf("Consultation : %s %s", $account->getFirstName(), $account->getName()));
|
||||||
|
|
||||||
|
$loginPagination = $paginator->paginate(
|
||||||
|
$accountLoginRegisterRepository->findBy(['account' => $account], ['loginAt' => 'DESC']),
|
||||||
|
$request->query->getInt('page', 1),
|
||||||
|
10,
|
||||||
|
['pageParameterName' => 'page']
|
||||||
|
);
|
||||||
|
|
||||||
|
$auditPagination = $paginator->paginate(
|
||||||
|
$auditLogRepository->findBy(['account' => $account], ['actionAt' => 'DESC']),
|
||||||
|
$request->query->getInt('audit_page', 1),
|
||||||
|
10,
|
||||||
|
['pageParameterName' => 'audit_page']
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->render('dashboard/administrateur/view.twig', [
|
return $this->render('dashboard/administrateur/view.twig', [
|
||||||
'admin' => $account,
|
'admin' => $account,
|
||||||
|
'form' => $form->createView(),
|
||||||
|
'passwordForm' => $passwordForm->createView(),
|
||||||
|
'loginRegisters' => $loginPagination,
|
||||||
|
'auditLogs' => $auditPagination
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifier un administrateur
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supprimer un administrateur
|
* Supprimer un administrateur
|
||||||
*/
|
*/
|
||||||
@@ -121,42 +258,73 @@ class AccountController extends AbstractController
|
|||||||
?Account $account,
|
?Account $account,
|
||||||
Request $request,
|
Request $request,
|
||||||
AppLogger $appLogger,
|
AppLogger $appLogger,
|
||||||
EntityManagerInterface $entityManager
|
EntityManagerInterface $entityManager,
|
||||||
): Response {
|
Mailer $mailer
|
||||||
|
): Response
|
||||||
|
{
|
||||||
if (!$account) {
|
if (!$account) {
|
||||||
$this->addFlash('error', 'Administrateur introuvable.');
|
$this->addFlash('error', 'Administrateur introuvable.');
|
||||||
return $this->redirectToRoute('app_crm_administrateur');
|
return $this->redirectToRoute('app_crm_administrateur');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($account === $this->getUser()) {
|
// --- SÉCURITÉ : Interdiction suppression ROOT ou CLIENT_MAIN ---
|
||||||
$this->addFlash('error', 'Vous ne pouvez pas supprimer votre propre compte.');
|
// On vérifie si l'un des rôles protégés est présent dans le tableau des rôles du compte
|
||||||
|
$protectedRoles = ['ROLE_ROOT', 'ROLE_CLIENT_MAIN'];
|
||||||
|
$accountRoles = $account->getRoles();
|
||||||
|
|
||||||
|
// Si l'intersection entre les rôles du compte et les rôles protégés n'est pas vide
|
||||||
|
if (array_intersect($protectedRoles, $accountRoles)) {
|
||||||
|
$title = "Blocage suppression compte HAUTE-HIÉRARCHIE";
|
||||||
|
$details = sprintf("Tentative de suppression de l'Administrateur protégé %s (Rôles: %s) par l'utilisateur %s",
|
||||||
|
$account->getEmail(),
|
||||||
|
implode(', ', $accountRoles),
|
||||||
|
$this->getUser()->getUserIdentifier()
|
||||||
|
);
|
||||||
|
|
||||||
|
$appLogger->record('SECURITY_CRITICAL', $details);
|
||||||
|
$this->sendSecurityEmail($mailer, $title, $details);
|
||||||
|
|
||||||
|
$this->addFlash('error', 'Sécurité : Ce compte est protégé et ne peut pas être supprimé.');
|
||||||
return $this->redirectToRoute('app_crm_administrateur');
|
return $this->redirectToRoute('app_crm_administrateur');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupération du token CSRF (compatible Turbo)
|
|
||||||
$token = $request->request->get('_token') ?? $request->query->get('_token');
|
$token = $request->request->get('_token') ?? $request->query->get('_token');
|
||||||
|
|
||||||
if ($this->isCsrfTokenValid('delete' . $account->getId(), $token)) {
|
if ($this->isCsrfTokenValid('delete' . $account->getId(), $token)) {
|
||||||
$name = $account->getFirstName() . ' ' . $account->getName();
|
$name = $account->getFirstName() . ' ' . $account->getName();
|
||||||
$email = $account->getEmail();
|
$email = $account->getEmail();
|
||||||
|
|
||||||
// Audit Log : On trace avant la suppression pour garder les infos
|
|
||||||
$appLogger->record(
|
$appLogger->record(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
sprintf("Suppression définitive de l'administrateur : %s (%s)", $name, $email)
|
sprintf("Suppression définitive de l'Administrateur : %s (%s)", $name, $email)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Dispatch pour notification mail
|
|
||||||
$eventDispatcher->dispatch(new EventAdminDeleted($account, $this->getUser()));
|
$eventDispatcher->dispatch(new EventAdminDeleted($account, $this->getUser()));
|
||||||
|
|
||||||
$entityManager->remove($account);
|
$entityManager->remove($account);
|
||||||
$entityManager->flush();
|
$entityManager->flush();
|
||||||
|
|
||||||
$this->addFlash('success', "Le compte de $name a été supprimé.");
|
$this->addFlash('success', "L'Administrateur $name a été supprimé.");
|
||||||
} else {
|
} else {
|
||||||
$this->addFlash('error', 'Jeton de sécurité invalide.');
|
$this->addFlash('error', 'Jeton de sécurité invalide.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('app_crm_administrateur');
|
return $this->redirectToRoute('app_crm_administrateur');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function sendSecurityEmail(Mailer $mailer, string $message, string $content): void
|
||||||
|
{
|
||||||
|
$mailer->send(
|
||||||
|
'notification@siteconseil.fr',
|
||||||
|
"Notification Intranet Ludikevent",
|
||||||
|
"[Intranet Ludikevent] - " . $message,
|
||||||
|
"mails/notification/security_violation.twig",
|
||||||
|
[
|
||||||
|
'message' => $message,
|
||||||
|
'content' => $content,
|
||||||
|
'account' => $this->getUser() ? $this->getUser()->getUserIdentifier() : 'Système',
|
||||||
|
'date' => new \DateTime(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Controller\Dashboard;
|
|||||||
|
|
||||||
use App\Entity\AuditLog;
|
use App\Entity\AuditLog;
|
||||||
use App\Repository\AuditLogRepository;
|
use App\Repository\AuditLogRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Knp\Component\Pager\PaginatorInterface;
|
use Knp\Component\Pager\PaginatorInterface;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||||
@@ -12,6 +13,7 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
class LogsController extends AbstractController
|
class LogsController extends AbstractController
|
||||||
{
|
{
|
||||||
@@ -41,18 +43,30 @@ class LogsController extends AbstractController
|
|||||||
{
|
{
|
||||||
$spreadsheet = new Spreadsheet();
|
$spreadsheet = new Spreadsheet();
|
||||||
$sheet = $spreadsheet->getActiveSheet();
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
$sheet->setTitle('Logs Intranet Ludikevent');
|
$sheet->setTitle('Logs Audit');
|
||||||
|
|
||||||
// En-têtes stylisés
|
// 1. Génération d'un mot de passe aléatoire pour ce fichier
|
||||||
$headers = ['Date', 'Heure', 'Admin', 'Email', 'Action', 'Message', 'URL', 'Navigateur/OS'];
|
// On utilise bin2hex pour un mot de passe robuste
|
||||||
foreach (range('A', 'G') as $i => $column) {
|
$ultraSecurePassword = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
// En-têtes (A à J)
|
||||||
|
$headers = ['Date', 'Heure', 'Admin', 'Email', 'Action', 'Message', 'URL', 'Navigateur/OS', 'Signature', 'État'];
|
||||||
|
$columns = range('A', 'J');
|
||||||
|
|
||||||
|
foreach ($columns as $i => $column) {
|
||||||
$sheet->setCellValue($column . '1', $headers[$i]);
|
$sheet->setCellValue($column . '1', $headers[$i]);
|
||||||
$sheet->getStyle($column . '1')->getFont()->setBold(true);
|
$sheet->getStyle($column . '1')->getFont()->setBold(true);
|
||||||
|
$sheet->getStyle($column . '1')->getFill()
|
||||||
|
->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
|
||||||
|
->getStartColor()->setARGB('F1F5F9');
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = 2;
|
$row = 2;
|
||||||
/** @var AuditLog $log */
|
/** @var AuditLog $log */
|
||||||
foreach ($logs as $log) {
|
foreach ($logs as $log) {
|
||||||
|
$isValid = ($log->getHashCode() === $log->generateSignature());
|
||||||
|
$statusText = $isValid ? 'VALIDE' : 'CORROMPU';
|
||||||
|
|
||||||
$sheet->setCellValue('A' . $row, $log->getActionAt()->format('d/m/Y'));
|
$sheet->setCellValue('A' . $row, $log->getActionAt()->format('d/m/Y'));
|
||||||
$sheet->setCellValue('B' . $row, $log->getActionAt()->format('H:i:s'));
|
$sheet->setCellValue('B' . $row, $log->getActionAt()->format('H:i:s'));
|
||||||
$sheet->setCellValue('C' . $row, $log->getAccount()->getFirstName() . ' ' . $log->getAccount()->getName());
|
$sheet->setCellValue('C' . $row, $log->getAccount()->getFirstName() . ' ' . $log->getAccount()->getName());
|
||||||
@@ -61,16 +75,77 @@ class LogsController extends AbstractController
|
|||||||
$sheet->setCellValue('F' . $row, $log->getMessage());
|
$sheet->setCellValue('F' . $row, $log->getMessage());
|
||||||
$sheet->setCellValue('G' . $row, $log->getPath());
|
$sheet->setCellValue('G' . $row, $log->getPath());
|
||||||
$sheet->setCellValue('H' . $row, $log->getUserAgent());
|
$sheet->setCellValue('H' . $row, $log->getUserAgent());
|
||||||
|
$sheet->setCellValue('I' . $row, $log->getHashCode());
|
||||||
|
$sheet->setCellValue('J' . $row, $statusText);
|
||||||
|
|
||||||
|
// Couleur d'état
|
||||||
|
$color = $isValid ? '059669' : 'DC2626';
|
||||||
|
$sheet->getStyle('J' . $row)->getFont()->setBold(true)->getColor()->setARGB($color);
|
||||||
|
|
||||||
$row++;
|
$row++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$writer = new Xlsx($spreadsheet);
|
// Auto-ajustement des colonnes
|
||||||
$response = new StreamedResponse(fn() => $writer->save('php://output'));
|
foreach ($columns as $column) {
|
||||||
|
$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- VERROUILLAGE DU FICHIER ---
|
||||||
|
|
||||||
|
// On protège la feuille contre toute modification
|
||||||
|
$sheet->getProtection()->setPassword($ultraSecurePassword);
|
||||||
|
$sheet->getProtection()->setSheet(true);
|
||||||
|
|
||||||
|
// On interdit la sélection des cellules verrouillées (optionnel)
|
||||||
|
$sheet->getProtection()->setSelectLockedCells(true);
|
||||||
|
$sheet->getProtection()->setSort(false);
|
||||||
|
$sheet->getProtection()->setInsertRows(false);
|
||||||
|
|
||||||
|
// On verrouille la structure du classeur
|
||||||
|
$spreadsheet->getSecurity()->setLockStructure(true);
|
||||||
|
$spreadsheet->getSecurity()->setWorkbookPassword($ultraSecurePassword);
|
||||||
|
$spreadsheet->getProperties()
|
||||||
|
->setCreator("Système Audit Ludikevent")
|
||||||
|
->setLastModifiedBy("Automate")
|
||||||
|
->setCompany("SARL SITECONSEIL")
|
||||||
|
->setCategory("Système Audit Ludikevent")
|
||||||
|
->setTitle("Journal d'audit protégé")
|
||||||
|
->setSubject("Confidentiel")
|
||||||
|
->setDescription("Ce fichier est protégé par signature numérique et verrouillage structurel.");
|
||||||
|
// --- GÉNÉRATION DE LA RÉPONSE ---
|
||||||
|
$writer = new Xlsx($spreadsheet);
|
||||||
|
|
||||||
|
$response = new StreamedResponse(function() use ($writer) {
|
||||||
|
$writer->save('php://output');
|
||||||
|
});
|
||||||
|
|
||||||
|
// On ajoute la date et un identifiant court dans le nom du fichier
|
||||||
|
$fileName = 'audit_ludikevent_' . date('d-m-Y_H-i') . '.xlsx';
|
||||||
|
|
||||||
$fileName = 'export_logs_' . date('d_m_Y') . '.xlsx';
|
|
||||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
$response->headers->set('Content-Disposition', 'attachment;filename="' . $fileName . '"');
|
$response->headers->set('Content-Disposition', 'attachment;filename="' . $fileName . '"');
|
||||||
|
$response->headers->set('Cache-Control', 'max-age=0');
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
#[Route(path: '/crm/logs/delete/{id}', name: 'app_crm_audit_logs_delete', methods: ['POST'])]
|
||||||
|
#[IsGranted('ROLE_ROOT')]
|
||||||
|
public function deleteLog(
|
||||||
|
AuditLog $log,
|
||||||
|
Request $request,
|
||||||
|
EntityManagerInterface $entityManager
|
||||||
|
): Response {
|
||||||
|
// Vérification du token CSRF dynamique (lié à l'ID du log)
|
||||||
|
if (!$this->isCsrfTokenValid('delete' . $log->getId(), $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Action non autorisée.');
|
||||||
|
return $this->redirectToRoute('app_crm_audit_logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityManager->remove($log);
|
||||||
|
$entityManager->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'L\'entrée du journal a été supprimée.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_crm_audit_logs');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class AuditLog
|
|||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
private ?string $userAgent = null;
|
private ?string $userAgent = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 64)]
|
||||||
|
private ?string $hashCode = null;
|
||||||
|
|
||||||
// Le constructeur force le remplissage des données dès le départ
|
// Le constructeur force le remplissage des données dès le départ
|
||||||
public function __construct(Account $account, string $type, string $message, string $path,string $userAgent)
|
public function __construct(Account $account, string $type, string $message, string $path,string $userAgent)
|
||||||
@@ -42,6 +44,22 @@ class AuditLog
|
|||||||
$this->path = $path;
|
$this->path = $path;
|
||||||
$this->actionAt = new \DateTimeImmutable();
|
$this->actionAt = new \DateTimeImmutable();
|
||||||
$this->userAgent = $userAgent;
|
$this->userAgent = $userAgent;
|
||||||
|
$this->hashCode = $this->generateSignature();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateSignature(): string
|
||||||
|
{
|
||||||
|
$data = sprintf(
|
||||||
|
'%s|%s|%s|%s|%s|%s',
|
||||||
|
$this->account->getEmail(), // L'email est plus fiable que l'ID si l'user est nouveau
|
||||||
|
$this->type,
|
||||||
|
$this->message,
|
||||||
|
$this->path,
|
||||||
|
$this->actionAt->format('Y-m-d H:i:s'),
|
||||||
|
$_ENV['APP_SECRET'] ?? 'default_secret'
|
||||||
|
);
|
||||||
|
|
||||||
|
return hash('sha256', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uniquement des Getters (Pas de Setters = Pas de modification possible en PHP)
|
// Uniquement des Getters (Pas de Setters = Pas de modification possible en PHP)
|
||||||
@@ -52,4 +70,5 @@ class AuditLog
|
|||||||
public function getMessage(): ?string { return $this->message; }
|
public function getMessage(): ?string { return $this->message; }
|
||||||
public function getPath(): ?string { return $this->path; }
|
public function getPath(): ?string { return $this->path; }
|
||||||
public function getUserAgent(): ?string{ return $this->userAgent;}
|
public function getUserAgent(): ?string{ return $this->userAgent;}
|
||||||
|
public function getHashCode(): ?string{ return $this->hashCode;}
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/Form/AccountPasswordType.php
Normal file
56
src/Form/AccountPasswordType.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Account;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\Length;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
|
||||||
|
class AccountPasswordType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('password', RepeatedType::class, [
|
||||||
|
'type' => PasswordType::class,
|
||||||
|
'invalid_message' => 'Les mots de passe doivent être identiques.',
|
||||||
|
'required' => true,
|
||||||
|
'first_options' => [
|
||||||
|
'label' => 'Nouveau mot de passe',
|
||||||
|
'attr' => [
|
||||||
|
'placeholder' => 'Entrez le nouveau mot de passe',
|
||||||
|
'class' => 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'second_options' => [
|
||||||
|
'label' => 'Confirmer le mot de passe',
|
||||||
|
'attr' => [
|
||||||
|
'placeholder' => 'Répétez le mot de passe',
|
||||||
|
'class' => 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank([
|
||||||
|
'message' => 'Veuillez entrer un mot de passe',
|
||||||
|
]),
|
||||||
|
new Length([
|
||||||
|
'min' => 8,
|
||||||
|
'minMessage' => 'Votre mot de passe doit contenir au moins {{ limit }} caractères',
|
||||||
|
'max' => 4096,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => Account::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,20 +4,39 @@ namespace App\Logger;
|
|||||||
|
|
||||||
use App\Entity\AuditLog;
|
use App\Entity\AuditLog;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
||||||
|
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||||
use Doctrine\ORM\Events;
|
use Doctrine\ORM\Events;
|
||||||
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
#[AsEntityListener(event: Events::preUpdate, method: 'preUpdate', entity: AuditLog::class)]
|
#[AsEntityListener(event: Events::preUpdate, method: 'preUpdate', entity: AuditLog::class)]
|
||||||
#[AsEntityListener(event: Events::preRemove, method: 'preRemove', entity: AuditLog::class)]
|
#[AsEntityListener(event: Events::preRemove, method: 'preRemove', entity: AuditLog::class)]
|
||||||
|
#[AsEntityListener(event: Events::prePersist, method: 'prePersist', entity: AuditLog::class)]
|
||||||
|
|
||||||
class AuditLogSecurityListener
|
class AuditLogSecurityListener
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Exécuté lors de la CRÉATION initiale du log
|
||||||
|
public function prePersist(AuditLog $log, PrePersistEventArgs $event): void
|
||||||
|
{
|
||||||
|
$log->generateSignature();
|
||||||
|
}
|
||||||
|
|
||||||
public function preUpdate(AuditLog $log, PreUpdateEventArgs $event): void
|
public function preUpdate(AuditLog $log, PreUpdateEventArgs $event): void
|
||||||
{
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ROOT')) {
|
||||||
throw new \LogicException("AuditLog est immuable : modification interdite.");
|
throw new \LogicException("AuditLog est immuable : modification interdite.");
|
||||||
}
|
}
|
||||||
|
$log->generateSignature();
|
||||||
|
}
|
||||||
|
|
||||||
public function preRemove(AuditLog $log): void
|
public function preRemove(AuditLog $log): void
|
||||||
{
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ROOT')) {
|
||||||
throw new \LogicException("AuditLog est protégé : suppression interdite.");
|
throw new \LogicException("AuditLog est protégé : suppression interdite.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,85 +3,136 @@
|
|||||||
{% block title %}Administrateurs{% endblock %}
|
{% block title %}Administrateurs{% endblock %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
{# Bouton Ajouter un administrateur #}
|
|
||||||
<a href="{{ path('app_crm_administrateur_add') }}"
|
<a href="{{ path('app_crm_administrateur_add') }}"
|
||||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 transition-all dark:bg-blue-600 dark:hover:bg-blue-700">
|
class="inline-flex items-center px-4 py-2 text-sm font-black uppercase tracking-widest text-white bg-indigo-600 rounded-xl hover:bg-indigo-700 shadow-lg shadow-indigo-500/20 transition-all active:scale-95">
|
||||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Ajouter un administrateur
|
Ajouter un Administrateur
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="p-4 md:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen w-full">
|
<div class="p-4 md:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen w-full">
|
||||||
|
{# Container élargi à 100% pour une visibilité totale #}
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<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>
|
{# HEADER #}
|
||||||
<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">
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-black text-gray-800 dark:text-white tracking-tight">Liste des Administrateurs</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 font-medium mt-1">Gestion des accès et des niveaux d'accréditation système.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="px-4 py-2 text-xs font-black text-indigo-600 bg-indigo-100 rounded-full dark:bg-indigo-900/40 dark:text-indigo-300 uppercase tracking-widest shadow-sm border border-indigo-200 dark:border-indigo-800">
|
||||||
{{ admins|length }} Administrateurs
|
{{ admins|length }} Administrateurs
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 shadow-md sm:rounded-lg border border-gray-200 dark:border-gray-700 overflow-x-auto">
|
{# TABLE CARD #}
|
||||||
|
<div class="bg-white dark:bg-[#1e293b] shadow-sm rounded-3xl border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||||
<thead class="text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-700 dark:text-gray-300">
|
<thead class="text-[10px] text-gray-400 uppercase bg-gray-50/50 dark:bg-gray-900/50 dark:text-gray-500 border-b border-gray-100 dark:border-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="px-6 py-4 font-bold">Utilisateur</th>
|
<th scope="col" class="px-8 py-5 font-black tracking-widest">Identité de l'Administrateur</th>
|
||||||
<th scope="col" class="px-6 py-4 font-bold">Email</th>
|
<th scope="col" class="px-8 py-5 font-black tracking-widest">Rôles & Habilitations</th>
|
||||||
<th scope="col" class="px-6 py-4 font-bold">Statut</th>
|
<th scope="col" class="px-8 py-5 font-black tracking-widest">Statut du compte</th>
|
||||||
<th scope="col" class="px-6 py-4 font-bold text-right">Actions</th>
|
<th scope="col" class="px-8 py-5 font-black tracking-widest text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{% for admin in admins %}
|
{% for admin in admins %}
|
||||||
<tr class="bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
<tr class="bg-white dark:bg-[#1e293b] hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
{# COLONNE 1 : IDENTITÉ #}
|
||||||
|
<td class="px-8 py-5">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="h-11 w-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-500 dark:text-slate-400 font-black text-xs border border-slate-200 dark:border-slate-700 shadow-sm transition-transform group-hover:scale-110">
|
||||||
|
{{ admin.firstName|first|upper }}{{ admin.name|first|upper }}
|
||||||
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">{{ admin.firstName }} {{ admin.name }}</span>
|
<span class="font-bold text-slate-900 dark:text-white text-base">{{ admin.firstName }} {{ admin.name }}</span>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400 italic">@{{ admin.username }}</span>
|
<span class="text-xs text-slate-400 font-medium tracking-tight font-mono">{{ admin.email }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap italic text-gray-400">
|
|
||||||
{{ admin.email }}
|
{# COLONNE 2 : RÔLES #}
|
||||||
|
<td class="px-8 py-5">
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{% for role in admin.roles %}
|
||||||
|
{% if role != 'ROLE_USER' %}
|
||||||
|
{% if role == 'ROLE_ROOT' %}
|
||||||
|
<span class="px-2 py-0.5 bg-red-500 text-white text-[9px] font-black uppercase rounded shadow-sm">ROOT</span>
|
||||||
|
{% elseif role == 'ROLE_CLIENT_MAIN' %}
|
||||||
|
<span class="px-2 py-0.5 bg-indigo-600 text-white text-[9px] font-black uppercase rounded shadow-sm">CLIENT PRINCIPAL</span>
|
||||||
|
{% elseif role == 'ROLE_ADMIN' %}
|
||||||
|
<span class="px-2 py-0.5 bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 text-[9px] font-black uppercase rounded border border-slate-200 dark:border-slate-700">ADMIN</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 bg-emerald-100 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[9px] font-bold uppercase rounded border border-emerald-200 dark:border-emerald-500/20">
|
||||||
|
{{ role|replace({'ROLE_ADMIN_': ''}) }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
|
{# COLONNE 3 : STATUT COULEUR (Vert/Ambre) #}
|
||||||
|
<td class="px-8 py-5 whitespace-nowrap">
|
||||||
{% if admin.actif %}
|
{% if admin.actif %}
|
||||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400">
|
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black uppercase tracking-widest bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 shadow-sm">
|
||||||
<span class="w-2 h-2 mr-2 bg-green-500 rounded-full animate-pulse"></span>
|
<span class="w-2 h-2 mr-2 bg-emerald-500 rounded-full animate-pulse"></span>
|
||||||
Actif
|
Accès Actif
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-400">
|
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black uppercase tracking-widest bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 shadow-sm">
|
||||||
<span class="w-2 h-2 mr-2 bg-red-500 rounded-full"></span>
|
<span class="w-2 h-2 mr-2 bg-amber-500 rounded-full"></span>
|
||||||
Suspendu
|
Accès Suspendu
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-right whitespace-nowrap space-x-2">
|
|
||||||
{# Bouton Voir #}
|
{# COLONNE 4 : ACTIONS #}
|
||||||
|
<td class="px-8 py-5 text-right whitespace-nowrap">
|
||||||
|
<div class="flex items-center justify-end space-x-3">
|
||||||
|
{# Bouton Gérer #}
|
||||||
<a href="{{ path('app_crm_administrateur_view', {id: admin.id}) }}"
|
<a href="{{ path('app_crm_administrateur_view', {id: admin.id}) }}"
|
||||||
class="inline-flex items-center px-3 py-2 text-xs font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-all">
|
class="flex items-center space-x-2 px-3 py-2 bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-indigo-600 hover:text-white dark:hover:bg-indigo-600 dark:hover:text-white rounded-xl transition-all border border-slate-200 dark:border-slate-700 font-bold text-xs shadow-sm"
|
||||||
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
|
title="Paramètres de l'Administrateur">
|
||||||
Gérer
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
<span>Gérer</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# Bouton Supprimer #}
|
{# Bouton Supprimer avec protection ROOT/CLIENT_MAIN #}
|
||||||
|
{% if 'ROLE_ROOT' not in admin.roles and 'ROLE_CLIENT_MAIN' not in admin.roles %}
|
||||||
<a href="{{ path('app_crm_administrateur_delete', {id: admin.id}) }}?_token={{ csrf_token('delete' ~ 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="Confirmer la suppression définitive de l'Administrateur {{ admin.firstName }} {{ admin.name }} ?"
|
||||||
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="p-2.5 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-xl transition-all border border-transparent hover:border-red-100 dark:hover:border-red-500/20"
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
title="Supprimer l'accès">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
</svg>
|
|
||||||
Supprimer
|
|
||||||
</a>
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-2.5 text-slate-200 dark:text-slate-700 bg-slate-50/50 dark:bg-slate-900/50 rounded-xl border border-slate-100 dark:border-slate-800 cursor-help"
|
||||||
|
title="Protection Système : Suppression impossible pour ce rang Administrateur">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 italic bg-gray-50/50 dark:bg-gray-800/50">
|
<td colspan="4" class="px-8 py-24 text-center">
|
||||||
Aucun administrateur trouvé.
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="w-20 h-20 bg-slate-50 dark:bg-slate-900/50 rounded-3xl flex items-center justify-center text-slate-200 dark:text-slate-800 mb-6">
|
||||||
|
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-400 font-bold text-lg">Aucun Administrateur enregistré</p>
|
||||||
|
<p class="text-slate-400/60 text-sm mt-1">Commencez par en ajouter un via le bouton en haut à droite.</p>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -90,4 +141,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
433
templates/dashboard/administrateur/view.twig
Normal file
433
templates/dashboard/administrateur/view.twig
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
{% extends 'dashboard/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Administrateur : {{ admin.firstName }} {{ admin.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a href="{{ path('app_crm_administrateur') }}" class="flex items-center space-x-2 px-4 py-2 text-slate-500 hover:text-slate-800 dark:hover:text-white transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||||
|
<span class="text-sm font-bold tracking-tight">Retour à la liste</span>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="max-w-full pb-12 px-6">
|
||||||
|
{# --- HEADER GÉNÉRAL --- #}
|
||||||
|
{# --- HEADER GÉNÉRAL : DESIGN ADMINISTRATEUR PRINCIPAL --- #}
|
||||||
|
<div class="mb-10 flex flex-col md:flex-row md:items-center justify-between gap-6 pb-8 border-b border-slate-800">
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
{# Avatar stylisé #}
|
||||||
|
<div class="h-20 w-20 rounded-3xl bg-gradient-to-br from-indigo-600 to-indigo-800 flex items-center justify-center text-white text-3xl font-black shadow-2xl shadow-indigo-500/20 border border-white/10">
|
||||||
|
{{ admin.firstName|first|upper }}{{ admin.name|first|upper }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col mb-1">
|
||||||
|
{# Badge Administrateur Principal #}
|
||||||
|
{% if 'ROLE_CLIENT_MAIN' in admin.roles %}
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<span class="px-3 py-1 bg-indigo-500 text-white text-[9px] font-black uppercase rounded-lg tracking-[0.2em] shadow-lg shadow-indigo-500/40">
|
||||||
|
Administrateur Principal
|
||||||
|
</span>
|
||||||
|
<div class="h-1.5 w-1.5 rounded-full bg-indigo-500 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h1 class="text-4xl font-black text-white tracking-tight">
|
||||||
|
{{ admin.firstName }} {{ admin.name }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex items-center space-x-2 text-slate-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" /></svg>
|
||||||
|
<span class="text-sm font-bold text-slate-300">{{ admin.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1 w-1 bg-slate-700 rounded-full"></div>
|
||||||
|
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest">System ID: #{{ admin.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
{# --- MODULE DE STATUT INTÉGRÉ --- #}
|
||||||
|
{% if not admin.actif %}
|
||||||
|
<div class="flex items-center bg-slate-900 border border-amber-500/30 rounded-2xl overflow-hidden shadow-2xl">
|
||||||
|
<div class="flex items-center px-5 py-3 space-x-3 bg-amber-500/5">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.8)]"></div>
|
||||||
|
<span class="text-[10px] font-black text-amber-500 uppercase tracking-widest">Compte non activé</span>
|
||||||
|
</div>
|
||||||
|
<a href="{{ path('app_crm_administrateur_view', {id: admin.id, act: 'updateStatut', status: 'true'}) }}"
|
||||||
|
class="px-6 py-3 bg-emerald-600 hover:bg-emerald-500 text-white text-[10px] font-black uppercase tracking-widest transition-all active:scale-95 border-l border-white/5">
|
||||||
|
Activer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center bg-slate-900 border border-emerald-500/30 rounded-2xl overflow-hidden shadow-2xl">
|
||||||
|
<div class="flex items-center px-5 py-3 space-x-3 bg-emerald-500/5">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.8)]"></div>
|
||||||
|
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest">Accès en ligne</span>
|
||||||
|
</div>
|
||||||
|
<a href="{{ path('app_crm_administrateur_view', {id: admin.id, act: 'updateStatut', status: 'false'}) }}"
|
||||||
|
onclick="return confirm('Voulez-vous vraiment désactiver cet accès ?')"
|
||||||
|
class="px-6 py-3 bg-slate-800 hover:bg-red-600 text-slate-400 hover:text-white text-[10px] font-black uppercase tracking-widest transition-all active:scale-95 border-l border-white/5">
|
||||||
|
Désactiver
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
|
||||||
|
{# --- COLONNE GAUCHE : FORMULAIRE IDENTITÉ (SANS LE TOGGLE ACTIF) --- #}
|
||||||
|
<div class="lg:col-span-4">
|
||||||
|
{{ form_start(form) }}
|
||||||
|
<div class="bg-white dark:bg-[#1e293b] rounded-3xl p-8 border border-slate-200 dark:border-slate-800 shadow-sm sticky top-6">
|
||||||
|
<div class="flex items-center space-x-4 mb-8">
|
||||||
|
<div class="h-16 w-16 rounded-2xl bg-indigo-600 flex items-center justify-center text-white text-2xl font-black shadow-lg shadow-indigo-500/20">
|
||||||
|
{{ admin.firstName|first|upper }}{{ admin.name|first|upper }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-black text-slate-800 dark:text-white uppercase tracking-tight">Identité</h2>
|
||||||
|
<p class="text-[10px] text-slate-400 font-black uppercase tracking-widest italic">Données personnelles</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 text-indigo-500">Prénom</label>
|
||||||
|
{{ form_widget(form.firstName, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-medium dark:text-white'} }) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 text-indigo-500">Nom</label>
|
||||||
|
{{ form_widget(form.name, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-medium dark:text-white'} }) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-slate-100 dark:border-slate-800 my-4">
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 text-amber-500">Username</label>
|
||||||
|
{{ form_widget(form.username, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-amber-500 transition-all text-sm font-mono dark:text-white'} }) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 text-amber-500">Email professionnel</label>
|
||||||
|
{{ form_widget(form.email, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-amber-500 transition-all text-sm dark:text-white'} }) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full mt-10 flex items-center justify-center space-x-2 px-6 py-4 bg-slate-900 dark:bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold shadow-xl transition-all active:scale-95">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
<span>Enregistrer l'identité</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# --- COLONNE DROITE : DROITS & HISTORIQUE --- #}
|
||||||
|
<div class="lg:col-span-8 space-y-8">
|
||||||
|
{# --- CARTE SÉCURITÉ & MOT DE PASSE --- #}
|
||||||
|
<div class="bg-white dark:bg-[#1e293b] rounded-3xl p-8 border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||||
|
<h3 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em] flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||||
|
Sécurité et Mot de passe
|
||||||
|
</h3>
|
||||||
|
<a href="{{ path('app_crm_administrateur_view', {id: admin.id, act: 'sendResetLink'}) }}"
|
||||||
|
onclick="return confirm('Envoyer l\'email de réinitialisation ?')"
|
||||||
|
class="px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-indigo-500 hover:text-white text-slate-600 dark:text-slate-300 text-[10px] font-black uppercase rounded-xl transition-all border border-slate-200 dark:border-slate-700">
|
||||||
|
Envoyer un lien de réinitialisation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_start(passwordForm) }}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Définir un nouveau mot de passe</label>
|
||||||
|
{{ form_widget(passwordForm.password.first, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm dark:text-white'} }) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Confirmer le mot de passe</label>
|
||||||
|
{{ form_widget(passwordForm.password.second, { 'attr': {'class': 'w-full px-4 py-3 bg-slate-50 dark:bg-slate-900 border-none rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm dark:text-white'} }) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button type="submit" class="px-6 py-3 bg-slate-800 text-white text-[10px] font-black uppercase rounded-xl hover:bg-slate-700 transition-all active:scale-95">
|
||||||
|
Mettre à jour le mot de passe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(passwordForm) }}
|
||||||
|
</div>
|
||||||
|
{# HABILITATIONS #}
|
||||||
|
{% if 'ROLE_CLIENT_MAIN' not in admin.roles %}
|
||||||
|
<form action="{{ path('app_crm_administrateur_view', {id: admin.id, act: 'updateRoles'}) }}" method="POST">
|
||||||
|
<div class="bg-white dark:bg-[#1e293b] rounded-3xl p-8 border border-slate-200 dark:border-slate-800 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h3 class="text-[10px] font-black text-emerald-500 uppercase tracking-[0.2em] flex items-center">
|
||||||
|
<span class="w-8 h-8 bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 rounded-lg flex items-center justify-center mr-3">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /></svg>
|
||||||
|
</span>
|
||||||
|
Habilitations Opérationnelles
|
||||||
|
</h3>
|
||||||
|
<button type="submit" class="px-5 py-2 bg-emerald-500 hover:bg-emerald-600 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg shadow-emerald-500/20">
|
||||||
|
Mettre à jour les droits
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
{% set availableRoles = [
|
||||||
|
{'role': 'ROLE_ADMIN_EDIT', 'label': 'Gestion des Administrateurs', 'icon': 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'},
|
||||||
|
{'role': 'ROLE_ADMIN_PRODUCT', 'label': 'Catalogue Produits', 'icon': 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4'},
|
||||||
|
{'role': 'ROLE_ADMIN_CONTRAT', 'label': 'Gestion Contrats', 'icon': 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'}
|
||||||
|
] %}
|
||||||
|
|
||||||
|
{% for item in availableRoles %}
|
||||||
|
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 flex items-center justify-between group">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-white dark:bg-slate-900 flex items-center justify-center text-slate-400 group-hover:text-emerald-500 transition-colors shadow-sm">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ item.icon }}" /></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-bold text-slate-700 dark:text-slate-200">{{ item.label }}</span>
|
||||||
|
<span class="block text-[10px] font-medium text-slate-400 uppercase tracking-widest mt-0.5">{{ item.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" name="roles[]" value="{{ item.role }}" class="sr-only peer" {% if item.role in admin.roles %}checked{% endif %}>
|
||||||
|
<div class="w-11 h-6 bg-slate-300 dark:bg-slate-700 rounded-full peer peer-checked:bg-emerald-500 transition-all after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-5"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-emerald-500/5 dark:bg-emerald-500/10 rounded-3xl p-8 border border-emerald-500/20 shadow-sm flex items-center space-x-6 border-l-4 border-l-emerald-500">
|
||||||
|
<div class="w-14 h-14 bg-emerald-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-emerald-600 dark:text-emerald-400 font-black text-sm uppercase tracking-wider">Habilitation Totale Active</h4>
|
||||||
|
<p class="text-emerald-600/70 dark:text-emerald-400/70 text-xs mt-1 leading-relaxed">Le rang Client Principal accorde nativement toutes les permissions système.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# PRIVILÈGE DE STRUCTURE (ROOT) #}
|
||||||
|
{% if is_granted('ROLE_ROOT') %}
|
||||||
|
<form action="{{ path('app_crm_administrateur_view', {id: admin.id, act: 'updateRoles'}) }}" method="POST">
|
||||||
|
<div class="bg-white dark:bg-[#1e293b] rounded-3xl p-8 border border-slate-200 dark:border-slate-800 shadow-sm border-l-4 border-l-indigo-500">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h3 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em] flex items-center">
|
||||||
|
<span class="w-8 h-8 bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-lg flex items-center justify-center mr-3">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
||||||
|
</span>
|
||||||
|
Privilège de Structure
|
||||||
|
</h3>
|
||||||
|
<button type="submit" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg shadow-indigo-500/20">
|
||||||
|
Confirmer le rang
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 rounded-2xl bg-[#111827] border border-slate-800 shadow-inner group transition-all hover:border-indigo-500/30">
|
||||||
|
<label class="flex items-center justify-between cursor-pointer">
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="checkbox" name="roles[]" value="ROLE_CLIENT_MAIN" class="sr-only peer" {% if 'ROLE_CLIENT_MAIN' in admin.roles %}checked{% endif %}>
|
||||||
|
<div class="w-14 h-7 bg-slate-700 rounded-full peer peer-checked:bg-indigo-600 transition-all duration-300"></div>
|
||||||
|
<div class="absolute top-1 left-1 w-5 h-5 bg-white rounded-full transition-all duration-300 peer-checked:translate-x-7 shadow-lg"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-bold text-white tracking-wide group-hover:text-indigo-400 transition-colors uppercase">Administrateur Client Principal</span>
|
||||||
|
<p class="text-[10px] text-slate-500 font-medium mt-1 uppercase italic">Contrôle total de l'organisation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# --- SECTIONS JOURNAUX (LOGS) --- #}
|
||||||
|
<div class="mt-12 space-y-12">
|
||||||
|
|
||||||
|
{# --- SECTION : JOURNAL D'AUDIT DU COMPTE --- #}
|
||||||
|
<div class="w-full bg-white dark:bg-[#1e293b] rounded-3xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden mt-12">
|
||||||
|
|
||||||
|
{# HEADER DU JOURNAL #}
|
||||||
|
<div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-slate-50/30 dark:bg-slate-800/30">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.3em]">Journal d'Audit Personnel</h3>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">Historique des actions effectuées par cet utilisateur</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="px-4 py-1.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 text-[10px] font-black uppercase rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||||
|
{{ auditLogs.getTotalItemCount }} ÉVÈNEMENTS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto custom-scrollbar">
|
||||||
|
<table class="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-50/50 dark:bg-slate-900/40 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Horodatage</th>
|
||||||
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Appareil / Source</th>
|
||||||
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em] text-center">Action</th>
|
||||||
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em] text-center">Intégrité</th>
|
||||||
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Détails</th>
|
||||||
|
{% if is_granted('ROLE_ROOT') %}
|
||||||
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em] text-right">Actions</th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 text-sm">
|
||||||
|
{% for log in auditLogs %}
|
||||||
|
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors duration-150">
|
||||||
|
|
||||||
|
{# 1. DATE #}
|
||||||
|
<td class="px-8 py-4 whitespace-nowrap align-top">
|
||||||
|
<div class="text-sm font-bold text-slate-700 dark:text-slate-200">{{ log.actionAt|date('d/m/Y') }}</div>
|
||||||
|
<div class="text-[10px] text-slate-400 font-mono mt-0.5 tracking-wider">{{ log.actionAt|date('H:i:s') }}</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{# 2. UA (ADMIN & APPAREIL) #}
|
||||||
|
<td class="px-8 py-4 whitespace-nowrap align-top">
|
||||||
|
{% if log.userAgent %}
|
||||||
|
{% set ua = log.userAgent|lower %}
|
||||||
|
<div class="flex items-center text-[10px] text-slate-500 bg-slate-100/50 dark:bg-slate-900/50 px-3 py-2 rounded-xl border border-slate-200/50 max-w-[240px]">
|
||||||
|
<span class="mr-2">
|
||||||
|
{% if 'firefox' in ua %}
|
||||||
|
<svg class="w-3.5 h-3.5 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M23.9 12c0 6.6-5.4 12-11.9 12C5.4 24 0 18.6 0 12S5.4 0 12 0c6.5 0 11.9 5.4 11.9 12zM10.8 4.7c-.5.1-1.3.4-1.3.4s.8-.2 1.3-.3c1.5-.4 3.1-.2 4.4.6 1.3.7 2.2 2 2.5 3.4.1.7.1 1.5-.1 2.2-.2 1-1.2 2.2-1.2 2.2s.8-.9 1-1.8c.3-1.4-.1-2.9-1.2-4-1.1-1.1-2.6-1.6-4.2-1.4-1.4.1-2.1.8-2.6 1.4-.5.6-.7 1.4-.6 2.1.1.7.5 1.4 1.1 1.8.6.4 1.3.5 2 .4.7-.1 1.3-.5 1.7-1.1.4-.6.5-1.3.3-2-.1-.7-.5-1.3-1-1.7s-1.2-.5-1.9-.4c-.7.1-1.3.5-1.7 1.1-.3.5-.4 1.1-.3 1.7.1.5.3.9.7 1.2.4.3.8.4 1.3.3.5-.1.9-.3 1.2-.7.2-.4.3-.8.2-1.3-.1-.4-.3-.7-.6-.9-.3-.2-.6-.3-1-.2-.3 0-.6.1-.8.4-.2.2-.3.5-.2.8.1.3.3.5.6.6.3.1.6 0 .8-.2.2-.2.3-.4.2-.7 0-.3-.2-.5-.5-.6-.2-.1-.5 0-.7.2s-.3.4-.2.7z"/></svg>
|
||||||
|
{% elseif 'chrome' in ua %}
|
||||||
|
<svg class="w-3.5 h-3.5 text-blue-400" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C8.21 0 4.83 1.75 2.64 4.5l3.96 6.84A6.004 6.004 0 0 1 12 6h10.36A12.012 12.012 0 0 0 12 0zm-1.04 13.5l-5.12-8.88A11.936 11.936 0 0 0 0 12c0 6.07 4.51 11.08 10.36 11.92l3.96-6.84a6.012 6.012 0 0 1-3.36-3.58zm12.4-7.5H12a6.002 6.002 0 0 1 3.36 9.42l-5.12 8.88C10.74 23.9 11.36 24 12 24c6.63 0 12-5.37 12-12 0-2.12-.55-4.12-1.52-5.88z"/></svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/></svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="truncate opacity-80" title="{{ log.userAgent }}">{{ log.userAgent }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate-400 italic text-[10px]">Source inconnue</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{# 3. TYPE D'ACTION #}
|
||||||
|
<td class="px-8 py-4 whitespace-nowrap text-center align-top">
|
||||||
|
{% set typeMapping = {
|
||||||
|
'CREATE': { 'label': 'CRÉATION', 'style': 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
|
||||||
|
'DELETE': { 'label': 'SUPPRESSION', 'style': 'bg-rose-500/10 text-rose-600 border-rose-500/20' },
|
||||||
|
'UPDATE': { 'label': 'MODIFICATION', 'style': 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
|
||||||
|
'AUTH': { 'label': 'CONNEXION', 'style': 'bg-amber-500/10 text-amber-600 border-amber-500/20' },
|
||||||
|
'VIEW': { 'label': 'CONSULTATION', 'style': 'bg-sky-500/10 text-sky-600 border-sky-500/20' },
|
||||||
|
'SECURITY_ALERT': { 'label': 'ALERTE SÉCURITÉ', 'style': 'bg-orange-500/10 text-orange-600 border-orange-500/20' },
|
||||||
|
'SECURITY_CRITICAL': { 'label': 'CRITIQUE', 'style': 'bg-red-600 text-white border-red-700 animate-pulse font-black' }
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{% set config = typeMapping[log.type] ?? { 'label': log.type, 'style': 'bg-slate-500/10 text-slate-500 border-slate-500/20' } %}
|
||||||
|
|
||||||
|
<span class="px-3 py-1.5 rounded-lg text-[10px] font-black border uppercase tracking-widest {{ config.style }}">
|
||||||
|
{{ config.label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{# 4. INTÉGRITÉ #}
|
||||||
|
<td class="px-8 py-4 whitespace-nowrap align-top text-center">
|
||||||
|
{% if log.hashCode == log.generateSignature %}
|
||||||
|
<div class="inline-flex flex-col items-center group cursor-help" title="Signature valide">
|
||||||
|
<div class="h-8 w-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-600 border border-emerald-500/20">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-[8px] font-black uppercase text-emerald-600 mt-1">Valide</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="inline-flex flex-col items-center animate-pulse cursor-help" title="Données altérées !">
|
||||||
|
<div class="h-8 w-8 rounded-lg bg-rose-500/10 flex items-center justify-center text-rose-600 border border-rose-500/20 shadow-lg shadow-rose-500/10">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-[8px] font-black uppercase text-rose-600 mt-1">Altéré</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{# 5. DÉTAILS #}
|
||||||
|
<td class="px-8 py-4 align-top">
|
||||||
|
<div class="text-sm text-slate-600 dark:text-slate-300 font-medium leading-relaxed max-w-xl">{{ log.message }}</div>
|
||||||
|
<div class="mt-2 flex items-center">
|
||||||
|
<code class="text-[10px] bg-slate-100 dark:bg-slate-900 px-2 py-0.5 rounded text-blue-500 font-mono italic">{{ log.path|default('/') }}</code>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{# 6. ACTIONS ROOT #}
|
||||||
|
{% if is_granted('ROLE_ROOT') %}
|
||||||
|
<td class="px-8 py-4 whitespace-nowrap text-right align-top">
|
||||||
|
<form action="{{ path('app_crm_audit_logs_delete', {id: log.id}) }}" method="POST" class="inline-block" onsubmit="return confirm('Supprimer ce log précis ?');">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ log.id) }}">
|
||||||
|
<button type="submit" class="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-900/10 rounded-lg transition-all">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ is_granted('ROLE_ROOT') ? 6 : 5 }}" class="px-8 py-12 text-center italic text-slate-400">
|
||||||
|
Aucun enregistrement d'audit pour ce compte.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# PAGINATION #}
|
||||||
|
{% if auditLogs.getTotalItemCount > 0 %}
|
||||||
|
<div class="px-8 py-6 bg-slate-50/50 dark:bg-slate-900/40 border-t border-slate-100 dark:border-slate-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold text-slate-400 uppercase tracking-widest">
|
||||||
|
Page {{ auditLogs.getCurrentPageNumber }} — {{ auditLogs|length }} logs affichés
|
||||||
|
</span>
|
||||||
|
<div class="navigation">
|
||||||
|
{{ knp_pagination_render(auditLogs) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{# CONNEXIONS #}
|
||||||
|
<div class="bg-white dark:bg-[#1e293b] rounded-3xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||||
|
<div class="p-8 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/10">
|
||||||
|
<h3 class="text-[10px] font-black text-emerald-500 uppercase tracking-[0.3em]">Dernières Connexions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left font-medium">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-50/50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<th class="px-8 py-4 text-[10px] font-black uppercase text-slate-400 tracking-widest">Date & Heure</th>
|
||||||
|
<th class="px-8 py-4 text-[10px] font-black uppercase text-slate-400 tracking-widest">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100 dark:divide-slate-800 text-sm">
|
||||||
|
{% for login in loginRegisters %}
|
||||||
|
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td class="px-8 py-5 text-slate-700 dark:text-slate-200">{{ login.loginAt|date('d/m/Y H:i') }}</td>
|
||||||
|
<td class="px-8 py-5">
|
||||||
|
<span class="px-3 py-1 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs font-mono text-slate-500 border border-slate-200 dark:border-slate-700">
|
||||||
|
{{ login.ip }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="2" class="px-8 py-12 text-center text-slate-400 text-sm italic">Aucune connexion enregistrée.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if loginRegisters.getTotalItemCount > 0 %}<div class="p-6 border-t border-slate-100 dark:border-slate-800">{{ knp_pagination_render(loginRegisters) }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,24 +2,27 @@
|
|||||||
|
|
||||||
{% block title %}Traçabilité des actions{% endblock %}
|
{% block title %}Traçabilité des actions{% endblock %}
|
||||||
|
|
||||||
{# BOUTON EXTRACTION DANS LE HEADER #}
|
{# HEADER : ACTIONS GLOBALES #}
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
{# Bouton Exporter XLSX #}
|
||||||
<a href="{{ path('app_crm_audit_logs', {extract: true}) }}" target="_blank" class="flex items-center space-x-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-bold rounded-xl transition-all shadow-lg shadow-emerald-500/20 group">
|
<a href="{{ path('app_crm_audit_logs', {extract: true}) }}" target="_blank" class="flex items-center space-x-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-bold rounded-xl transition-all shadow-lg shadow-emerald-500/20 group">
|
||||||
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Exporter XLSX</span>
|
<span>Exporter XLSX</span>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="w-full bg-white dark:bg-[#1e293b] rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
<div class="w-full bg-white dark:bg-[#1e293b] rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||||
|
|
||||||
{# Header du tableau avec statistiques rapides #}
|
{# STATISTIQUES #}
|
||||||
<div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-slate-50/30 dark:bg-slate-800/30">
|
<div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-slate-50/30 dark:bg-slate-800/30">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-slate-800 dark:text-white tracking-tight">Journal d'Audit</h2>
|
<h2 class="text-xl font-bold text-slate-800 dark:text-white tracking-tight">Journal d'Audit</h2>
|
||||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">Historique complet des interactions sur l'Intranet</p>
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">Historique sécurisé par signature cryptographique</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="px-4 py-1.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 text-[10px] font-black uppercase rounded-lg border border-blue-100 dark:border-blue-800/50">
|
<span class="px-4 py-1.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 text-[10px] font-black uppercase rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||||
@@ -35,20 +38,26 @@
|
|||||||
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Horodatage</th>
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Horodatage</th>
|
||||||
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Administrateur & Appareil</th>
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Administrateur & Appareil</th>
|
||||||
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em] text-center">Action</th>
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em] text-center">Action</th>
|
||||||
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Détails de l'activité</th>
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em] text-center">Intégrité</th>
|
||||||
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Détails</th>
|
||||||
|
|
||||||
|
{# COLONNE ACTIONS VISIBLE UNIQUEMENT POUR ROOT #}
|
||||||
|
{% if is_granted('ROLE_ROOT') %}
|
||||||
|
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em] text-right">Actions</th>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors duration-150">
|
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors duration-150">
|
||||||
|
|
||||||
{# 1. DATE & HEURE #}
|
{# 1. DATE #}
|
||||||
<td class="px-8 py-4 whitespace-nowrap align-top">
|
<td class="px-8 py-4 whitespace-nowrap align-top">
|
||||||
<div class="text-sm font-bold text-slate-700 dark:text-slate-200">{{ log.actionAt|date('d/m/Y') }}</div>
|
<div class="text-sm font-bold text-slate-700 dark:text-slate-200">{{ log.actionAt|date('d/m/Y') }}</div>
|
||||||
<div class="text-[10px] text-slate-400 font-mono mt-0.5 tracking-wider">{{ log.actionAt|date('H:i:s') }}</div>
|
<div class="text-[10px] text-slate-400 font-mono mt-0.5 tracking-wider">{{ log.actionAt|date('H:i:s') }}</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# 2. ADMINISTRATEUR + USER AGENT #}
|
{# 2. ADMIN & UA #}
|
||||||
<td class="px-8 py-4 whitespace-nowrap align-top">
|
<td class="px-8 py-4 whitespace-nowrap align-top">
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<div class="h-10 w-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 flex flex-shrink-0 items-center justify-center text-white font-bold text-xs shadow-md mt-0.5">
|
<div class="h-10 w-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 flex flex-shrink-0 items-center justify-center text-white font-bold text-xs shadow-md mt-0.5">
|
||||||
@@ -58,65 +67,94 @@
|
|||||||
<div class="text-sm font-bold text-slate-800 dark:text-white flex items-center mb-0.5">
|
<div class="text-sm font-bold text-slate-800 dark:text-white flex items-center mb-0.5">
|
||||||
{{ log.account.firstName }} {{ log.account.name }}
|
{{ log.account.firstName }} {{ log.account.name }}
|
||||||
{% if 'ROLE_ROOT' in log.account.roles %}
|
{% if 'ROLE_ROOT' in log.account.roles %}
|
||||||
<span class="ml-2 px-1.5 py-0.5 rounded text-[8px] bg-red-600/10 text-red-600 dark:bg-red-500/20 dark:text-red-400 font-black uppercase tracking-tighter border border-red-200 dark:border-red-900/50">Root</span>
|
<span class="ml-2 px-1.5 py-0.5 rounded text-[8px] bg-red-600/10 text-red-600 dark:bg-red-500/20 dark:text-red-400 font-black uppercase border border-red-200 dark:border-red-900/50">Root</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[11px] text-slate-400 dark:text-slate-500 mb-2 font-medium">{{ log.account.email }}</div>
|
<div class="text-[11px] text-slate-400 dark:text-slate-500 mb-2">{{ log.account.email }}</div>
|
||||||
|
|
||||||
{# Bloc User Agent avec icône SVG dynamique #}
|
|
||||||
{% if log.userAgent %}
|
{% if log.userAgent %}
|
||||||
{% set ua = log.userAgent|lower %}
|
{% set ua = log.userAgent|lower %}
|
||||||
<div class="flex items-center text-[10px] text-slate-500 dark:text-slate-400 bg-slate-100/50 dark:bg-slate-900/50 px-2 py-1.5 rounded-lg border border-slate-200/50 dark:border-slate-800 max-w-[260px] group/ua">
|
<div class="flex items-center text-[10px] text-slate-500 bg-slate-100/50 dark:bg-slate-900/50 px-2 py-1.5 rounded-lg border border-slate-200/50 max-w-[260px]">
|
||||||
<span class="mr-2 flex-shrink-0">
|
<span class="mr-2">
|
||||||
{% if 'firefox' in ua %}
|
{% if 'firefox' in ua %}
|
||||||
<svg class="w-3.5 h-3.5 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M23.9 12c0 6.6-5.4 12-11.9 12C5.4 24 0 18.6 0 12S5.4 0 12 0c6.5 0 11.9 5.4 11.9 12zM10.8 4.7c-.5.1-1.3.4-1.3.4s.8-.2 1.3-.3c1.5-.4 3.1-.2 4.4.6 1.3.7 2.2 2 2.5 3.4.1.7.1 1.5-.1 2.2-.2 1-1.2 2.2-1.2 2.2s.8-.9 1-1.8c.3-1.4-.1-2.9-1.2-4-1.1-1.1-2.6-1.6-4.2-1.4-1.4.1-2.1.8-2.6 1.4-.5.6-.7 1.4-.6 2.1.1.7.5 1.4 1.1 1.8.6.4 1.3.5 2 .4.7-.1 1.3-.5 1.7-1.1.4-.6.5-1.3.3-2-.1-.7-.5-1.3-1-1.7s-1.2-.5-1.9-.4c-.7.1-1.3.5-1.7 1.1-.3.5-.4 1.1-.3 1.7.1.5.3.9.7 1.2.4.3.8.4 1.3.3.5-.1.9-.3 1.2-.7.2-.4.3-.8.2-1.3-.1-.4-.3-.7-.6-.9-.3-.2-.6-.3-1-.2-.3 0-.6.1-.8.4-.2.2-.3.5-.2.8.1.3.3.5.6.6.3.1.6 0 .8-.2.2-.2.3-.4.2-.7 0-.3-.2-.5-.5-.6-.2-.1-.5 0-.7.2s-.3.4-.2.7z"/></svg>
|
<svg class="w-3.5 h-3.5 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M23.9 12c0 6.6-5.4 12-11.9 12C5.4 24 0 18.6 0 12S5.4 0 12 0c6.5 0 11.9 5.4 11.9 12zM10.8 4.7c-.5.1-1.3.4-1.3.4s.8-.2 1.3-.3c1.5-.4 3.1-.2 4.4.6 1.3.7 2.2 2 2.5 3.4.1.7.1 1.5-.1 2.2-.2 1-1.2 2.2-1.2 2.2s.8-.9 1-1.8c.3-1.4-.1-2.9-1.2-4-1.1-1.1-2.6-1.6-4.2-1.4-1.4.1-2.1.8-2.6 1.4-.5.6-.7 1.4-.6 2.1.1.7.5 1.4 1.1 1.8.6.4 1.3.5 2 .4.7-.1 1.3-.5 1.7-1.1.4-.6.5-1.3.3-2-.1-.7-.5-1.3-1-1.7s-1.2-.5-1.9-.4c-.7.1-1.3.5-1.7 1.1-.3.5-.4 1.1-.3 1.7.1.5.3.9.7 1.2.4.3.8.4 1.3.3.5-.1.9-.3 1.2-.7.2-.4.3-.8.2-1.3-.1-.4-.3-.7-.6-.9-.3-.2-.6-.3-1-.2-.3 0-.6.1-.8.4-.2.2-.3.5-.2.8.1.3.3.5.6.6.3.1.6 0 .8-.2.2-.2.3-.4.2-.7 0-.3-.2-.5-.5-.6-.2-.1-.5 0-.7.2s-.3.4-.2.7z"/></svg>
|
||||||
{% elseif 'edg/' in ua %}
|
|
||||||
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 18.75c-3.728 0-6.75-3.022-6.75-6.75s3.022-6.75 6.75-6.75 6.75 3.022 6.75 6.75-3.022 6.75-6.75 6.75z"/></svg>
|
|
||||||
{% elseif 'chrome' in ua %}
|
{% elseif 'chrome' in ua %}
|
||||||
<svg class="w-3.5 h-3.5 text-blue-400" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C8.21 0 4.83 1.75 2.64 4.5l3.96 6.84A6.004 6.004 0 0 1 12 6h10.36A12.012 12.012 0 0 0 12 0zm-1.04 13.5l-5.12-8.88A11.936 11.936 0 0 0 0 12c0 6.07 4.51 11.08 10.36 11.92l3.96-6.84a6.012 6.012 0 0 1-3.36-3.58zm12.4-7.5H12a6.002 6.002 0 0 1 3.36 9.42l-5.12 8.88C10.74 23.9 11.36 24 12 24c6.63 0 12-5.37 12-12 0-2.12-.55-4.12-1.52-5.88z"/></svg>
|
<svg class="w-3.5 h-3.5 text-blue-400" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C8.21 0 4.83 1.75 2.64 4.5l3.96 6.84A6.004 6.004 0 0 1 12 6h10.36A12.012 12.012 0 0 0 12 0zm-1.04 13.5l-5.12-8.88A11.936 11.936 0 0 0 0 12c0 6.07 4.51 11.08 10.36 11.92l3.96-6.84a6.012 6.012 0 0 1-3.36-3.58zm12.4-7.5H12a6.002 6.002 0 0 1 3.36 9.42l-5.12 8.88C10.74 23.9 11.36 24 12 24c6.63 0 12-5.37 12-12 0-2.12-.55-4.12-1.52-5.88z"/></svg>
|
||||||
{% elseif 'safari' in ua and 'chrome' not in ua %}
|
|
||||||
<svg class="w-3.5 h-3.5 text-sky-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/><path d="M14.5 9.5L9.5 14.5M14.5 9.5L12 3M14.5 9.5L21 12M9.5 14.5L12 21M9.5 14.5L3 12"/></svg>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
<svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/></svg>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate font-mono opacity-80" title="{{ log.userAgent }}">
|
<span class="truncate opacity-80" title="{{ log.userAgent }}">{{ log.userAgent }}</span>
|
||||||
{{ log.userAgent }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# 3. BADGE ACTION #}
|
{# 3. TYPE D'ACTION TRADUIT #}
|
||||||
|
{# 3. TYPE D'ACTION TRADUIT ET STYLISÉ #}
|
||||||
<td class="px-8 py-4 whitespace-nowrap text-center align-top">
|
<td class="px-8 py-4 whitespace-nowrap text-center align-top">
|
||||||
{% set typeStyles = {
|
{% set typeMapping = {
|
||||||
'CREATE': 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
|
'CREATE': { 'label': 'CRÉATION', 'style': 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
|
||||||
'DELETE': 'bg-rose-500/10 text-rose-600 border-rose-500/20',
|
'DELETE': { 'label': 'SUPPRESSION', 'style': 'bg-rose-500/10 text-rose-600 border-rose-500/20' },
|
||||||
'VIEW': 'bg-sky-500/10 text-sky-600 border-sky-500/20',
|
'UPDATE': { 'label': 'MODIFICATION', 'style': 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
|
||||||
'AUTH': 'bg-amber-500/10 text-amber-600 border-amber-500/20'
|
'AUTH': { 'label': 'CONNEXION', 'style': 'bg-amber-500/10 text-amber-600 border-amber-500/20' },
|
||||||
|
'VIEW': { 'label': 'CONSULTATION', 'style': 'bg-sky-500/10 text-sky-600 border-sky-500/20' },
|
||||||
|
'SECURITY_ALERT': { 'label': 'ALERTE SÉCURITÉ', 'style': 'bg-orange-500/10 text-orange-600 border-orange-500/20' },
|
||||||
|
'SECURITY_CRITICAL': { 'label': 'CRITIQUE', 'style': 'bg-red-600 text-white border-red-700 animate-pulse font-black' }
|
||||||
} %}
|
} %}
|
||||||
<span class="px-3 py-1.5 rounded-lg text-[10px] font-black border uppercase tracking-widest {{ typeStyles[log.type] ?? 'bg-slate-500/10 text-slate-500 border-slate-500/20' }}">
|
|
||||||
{{ log.type }}
|
{# On récupère la configuration ou on utilise une valeur par défaut #}
|
||||||
|
{% set config = typeMapping[log.type] ?? { 'label': log.type, 'style': 'bg-slate-500/10 text-slate-500 border-slate-500/20' } %}
|
||||||
|
|
||||||
|
<span class="px-3 py-1.5 rounded-lg text-[10px] font-black border uppercase tracking-widest {{ config.style }}">
|
||||||
|
{{ config.label }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
{# 4. INTÉGRITÉ (HASH CHECK) #}
|
||||||
|
<td class="px-8 py-4 whitespace-nowrap align-top text-center">
|
||||||
|
{% if log.hashCode == log.generateSignature %}
|
||||||
|
<div class="inline-flex flex-col items-center group cursor-help" title="Signature valide">
|
||||||
|
<div class="h-8 w-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-600 border border-emerald-500/20">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-[8px] font-black uppercase text-emerald-600 mt-1">Valide</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="inline-flex flex-col items-center animate-pulse cursor-help" title="Données altérées !">
|
||||||
|
<div class="h-8 w-8 rounded-lg bg-rose-500/10 flex items-center justify-center text-rose-600 border border-rose-500/20 shadow-lg shadow-rose-500/10">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-[8px] font-black uppercase text-rose-600 mt-1">Altéré</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
{# 4. MESSAGE ET CHEMIN #}
|
{# 5. DÉTAILS #}
|
||||||
<td class="px-8 py-4 align-top">
|
<td class="px-8 py-4 align-top">
|
||||||
<div class="text-sm text-slate-600 dark:text-slate-300 font-medium leading-relaxed max-w-2xl">{{ log.message }}</div>
|
<div class="text-sm text-slate-600 dark:text-slate-300 font-medium leading-relaxed max-w-xl">{{ log.message }}</div>
|
||||||
<div class="mt-2 flex items-center">
|
<div class="mt-2 flex items-center">
|
||||||
<span class="text-[9px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-tighter mr-2">URL :</span>
|
<code class="text-[10px] bg-slate-100 dark:bg-slate-900 px-2 py-0.5 rounded text-blue-500 font-mono">{{ log.path }}</code>
|
||||||
<code class="text-[10px] bg-slate-100 dark:bg-slate-900 px-2 py-0.5 rounded text-blue-500 dark:text-blue-400 font-mono">
|
|
||||||
{{ log.path }}
|
|
||||||
</code>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{# 6. CELLULE ACTIONS VISIBLE UNIQUEMENT POUR ROOT #}
|
||||||
|
{% if is_granted('ROLE_ROOT') %}
|
||||||
|
<td class="px-8 py-4 whitespace-nowrap text-right align-top">
|
||||||
|
<form action="{{ path('app_crm_audit_logs_delete', {id: log.id}) }}" method="POST" class="inline-block" onsubmit="return confirm('Supprimer ce log précis ?');">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ log.id) }}">
|
||||||
|
<button type="submit" class="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-900/10 rounded-lg transition-all">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="px-8 py-12 text-center">
|
{# On ajuste le colspan dynamiquement #}
|
||||||
<div class="text-slate-400 dark:text-slate-600 italic">Aucun log trouvé dans cette période.</div>
|
<td colspan="{{ is_granted('ROLE_ROOT') ? 6 : 5 }}" class="px-8 py-12 text-center italic text-slate-400">
|
||||||
|
Aucun enregistrement.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -124,11 +162,11 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# FOOTER & PAGINATION #}
|
{# PAGINATION #}
|
||||||
<div class="px-8 py-6 bg-slate-50/50 dark:bg-slate-900/40 border-t border-slate-100 dark:border-slate-800">
|
<div class="px-8 py-6 bg-slate-50/50 dark:bg-slate-900/40 border-t border-slate-100 dark:border-slate-800">
|
||||||
<div class="flex flex-col md:flex-row items-center justify-between gap-6">
|
<div class="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||||
<div class="text-xs font-semibold text-slate-400 uppercase tracking-widest">
|
<div class="text-xs font-semibold text-slate-400 uppercase tracking-widest">
|
||||||
Page {{ logs.getCurrentPageNumber }} — Affichage de {{ logs|length }} logs
|
Page {{ logs.getCurrentPageNumber }} — {{ logs|length }} logs affichés
|
||||||
</div>
|
</div>
|
||||||
<div class="navigation shadow-sm rounded-xl overflow-hidden">
|
<div class="navigation shadow-sm rounded-xl overflow-hidden">
|
||||||
{{ knp_pagination_render(logs) }}
|
{{ knp_pagination_render(logs) }}
|
||||||
|
|||||||
48
templates/mails/account/password-modify.twig
Normal file
48
templates/mails/account/password-modify.twig
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends 'mails/base.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<mj-section>
|
||||||
|
<mj-column background-color="#ffffff" border-radius="24px" padding="40px" border="1px solid #e2e8f0">
|
||||||
|
|
||||||
|
<mj-text font-size="22px" font-weight="900" color="#0f172a" padding-bottom="10px">
|
||||||
|
Mise à jour de vos accès
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-text>
|
||||||
|
Bonjour <strong>{{ datas.account.firstName }}</strong>,
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-text>
|
||||||
|
Nous vous informons que votre mot de passe pour accéder à l'Intranet a été réinitialisé par <strong>{{ datas.who.firstName }} {{ datas.who.name }}</strong>.
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-section background-color="#fdf2f2" border-radius="12px" border="1px solid #fee2e2" padding="20px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text font-size="12px" font-weight="700" color="#b91c1c" text-transform="uppercase" letter-spacing="1px" padding-bottom="5px">
|
||||||
|
Nouveau mot de passe provisoire
|
||||||
|
</mj-text>
|
||||||
|
<mj-text font-family="monospace" font-size="18px" font-weight="bold" color="#991b1b">
|
||||||
|
{{ datas.password }}
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
<mj-text padding-top="20px" font-size="13px" color="#64748b italic">
|
||||||
|
Par mesure de sécurité, nous vous recommandons de modifier ce mot de passe dès votre première connexion dans l'onglet "Mon Profil".
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-button background-color="#1e293b" color="#ffffff" border-radius="12px" font-weight="700" inner-padding="15px 30px" href="{{ url('app_home') }}" padding-top="25px">
|
||||||
|
SE CONNECTER
|
||||||
|
</mj-button>
|
||||||
|
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
<mj-section padding-top="0px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text align="center" font-size="11px" color="#94a3b8">
|
||||||
|
Si vous n'êtes pas à l'origine de cette modification ou si vous n'étiez pas prévenu, contactez immédiatement la direction.
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
{% endblock %}
|
||||||
44
templates/mails/account/status-update.twig
Normal file
44
templates/mails/account/status-update.twig
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends 'mails/base.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<mj-section>
|
||||||
|
<mj-column background-color="#ffffff" border-radius="24px" padding="40px" border="1px solid #e2e8f0">
|
||||||
|
|
||||||
|
<mj-text font-size="22px" font-weight="900" color="#0f172a" padding-bottom="10px">
|
||||||
|
Statut de votre accès mis à jour
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-text>
|
||||||
|
Bonjour <strong>{{ datas.account.firstName }}</strong>,
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-text>
|
||||||
|
L'état de votre compte sur l'Intranet Ludikevent a été modifié par <strong>{{ datas.who.firstName }} {{ datas.who.name }}</strong>.
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-section background-color="{{ datas.stats ? '#ecfdf5' : '#fff1f2' }}" border-radius="12px" border="1px solid {{ datas.stats ? '#d1fae5' : '#ffe4e6' }}" padding="20px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text align="center" font-size="12px" font-weight="900" color="{{ datas.stats ? '#065f46' : '#9f1239' }}" text-transform="uppercase" letter-spacing="2px">
|
||||||
|
Nouveau statut : {{ datas.stats ? 'COMPTE ACTIVÉ' : 'COMPTE BLOQUÉ' }}
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
{% if datas.stats %}
|
||||||
|
<mj-text padding-top="20px">
|
||||||
|
Votre accès est désormais opérationnel. Vous pouvez vous connecter à votre tableau de bord dès maintenant.
|
||||||
|
</mj-text>
|
||||||
|
<mj-button background-color="#10b981" color="#ffffff" border-radius="12px" font-weight="700" inner-padding="15px 30px" href="{{ url('app_home') }}" padding-top="20px">
|
||||||
|
ACCÉDER AU CRM
|
||||||
|
</mj-button>
|
||||||
|
{% else %}
|
||||||
|
<mj-text padding-top="20px">
|
||||||
|
Votre accès a été suspendu. Si vous pensez qu'il s'agit d'une erreur, nous vous invitons à contacter votre responsable ou l'Administrateur Principal.
|
||||||
|
</mj-text>
|
||||||
|
<mj-button background-color="#f43f5e" color="#ffffff" border-radius="12px" font-weight="700" inner-padding="15px 30px" href="mailto:jovann@siteconseil.fr" padding-top="20px">
|
||||||
|
CONTACTER LE SUPPORT
|
||||||
|
</mj-button>
|
||||||
|
{% endif %}
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,63 +1,39 @@
|
|||||||
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.
|
{% extends 'mails/base.twig' %}
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<mj-section>
|
<mj-section>
|
||||||
<mj-column>
|
<mj-column background-color="#ffffff" border-radius="24px" padding="40px" border="1px solid #e2e8f0">
|
||||||
<mj-text font-size="22px" font-weight="bold" color="#007bff">
|
|
||||||
|
<mj-text font-size="24px" font-weight="900" color="#0f172a" padding-bottom="10px">
|
||||||
Bienvenue, {{ datas.admin.firstName }} !
|
Bienvenue, {{ datas.admin.firstName }} !
|
||||||
</mj-text>
|
</mj-text>
|
||||||
|
|
||||||
<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>.
|
Votre accès administrateur a été configuré avec succès par <strong>{{ datas.creator.firstName }} {{ datas.creator.name }}</strong>.
|
||||||
</mj-text>
|
</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-divider border-width="1px" border-color="#f1f5f9" padding="20px 0" />
|
||||||
|
|
||||||
|
<mj-text font-weight="700" color="#64748b" text-transform="uppercase" font-size="12px" letter-spacing="1px">
|
||||||
|
Vos accès de connexion
|
||||||
</mj-text>
|
</mj-text>
|
||||||
<mj-button href="{{ datas.setup_url }}">
|
|
||||||
ACTIVER MON COMPTE
|
<mj-section background-color="#f1f5f9" border-radius="12px" padding="15px">
|
||||||
</mj-button>
|
<mj-column>
|
||||||
<mj-text font-size="14px" color="#666666">
|
<mj-text font-family="monospace" font-size="16px" color="#1e293b" align="center">
|
||||||
Ce lien est valable pendant 24 heures (expire le {{ datas.expires_at|date('d/m/Y à H:i') }}).
|
{{ datas.admin.email }}
|
||||||
</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-text>
|
||||||
</mj-column>
|
</mj-column>
|
||||||
</mj-section>
|
</mj-section>
|
||||||
|
|
||||||
<mj-section background-color="#f4f4f4">
|
<mj-text padding-top="25px">
|
||||||
<mj-column>
|
Pour des raisons de sécurité, vous devez définir votre propre mot de passe avant de pouvoir accéder au tableau de bord. Ce lien est valable jusqu'au <strong>{{ datas.expires_at|date('d/m/Y à H:i') }}</strong>.
|
||||||
<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-text>
|
||||||
|
|
||||||
|
<mj-button background-color="#4f46e5" color="#ffffff" border-radius="12px" font-weight="900" inner-padding="18px 35px" href="{{ datas.setup_url }}" padding-top="30px">
|
||||||
|
ACTIVER MON ACCÈS MAINTENANT
|
||||||
|
</mj-button>
|
||||||
|
|
||||||
</mj-column>
|
</mj-column>
|
||||||
</mj-section>
|
</mj-section>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
42
templates/mails/notification/security_violation.twig
Normal file
42
templates/mails/notification/security_violation.twig
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends 'mails/base.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<mj-section background-color="#ffffff" padding-top="0px">
|
||||||
|
<mj-column width="100%">
|
||||||
|
<mj-text font-size="20px" font-weight="bold" color="#dc2626">
|
||||||
|
⚠️ Violation de Sécurité Détectée
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-text font-size="16px" color="#334155" line-height="24px">
|
||||||
|
Une action critique a été interceptée et bloquée par le système de sécurité.
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-section background-color="#f8fafc" border="1px solid #e2e8f0" padding="15px">
|
||||||
|
<mj-column width="100%">
|
||||||
|
<mj-text font-size="14px" color="#475569" padding-bottom="8px">
|
||||||
|
<strong>Action demandée :</strong> <span style="color: #dc2626;">{{ datas.message }}</span>
|
||||||
|
</mj-text>
|
||||||
|
<mj-text font-size="14px" color="#475569" padding-bottom="8px">
|
||||||
|
<strong>Compte à l'origine :</strong> {{ datas.account }}
|
||||||
|
</mj-text>
|
||||||
|
<mj-text font-size="14px" color="#475569">
|
||||||
|
<strong>Compte visé :</strong> Administrateur protégé (ROOT)
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
<mj-section background-color="#fef2f2" border-left="4px solid #dc2626" padding="15px">
|
||||||
|
<mj-column width="100%">
|
||||||
|
<mj-text font-size="13px" font-style="italic" color="#991b1b">
|
||||||
|
{{ datas.content }}
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
<mj-text font-size="12px" color="#94a3b8" padding-top="20px">
|
||||||
|
<strong>Date de l'événement :</strong> {{ datas.date|date('d/m/Y H:i:s') }} <br/>
|
||||||
|
<strong>Localisation :</strong> Système Audit Intranet Ludikevent
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user