feat(dashboard/profil): Ajoute la page de profil utilisateur

Ajoute la page de profil utilisateur avec formulaire de mot de passe, gestion 2FA, et déconnexion.
 feat(AuditLogRepository): Améliore requête logs avec sécurité et filtre

Améliore la requête des logs en appliquant les restrictions de sécurité et le filtrage optionnel par compte.
 feat(ProfilsController): Crée le contrôleur des profils utilisateurs

Crée le contrôleur des profils utilisateurs pour gérer la sécurité du compte (2FA et mot de passe).
🎨 feat(dashboard/audit_logs): Améliore l'interface des journaux d'audit

Améliore l'interface des journaux d'audit avec filtre par compte et design plus moderne.
```
This commit is contained in:
Serreau Jovann
2026-01-16 11:34:39 +01:00
parent 777375e5e8
commit 667da6af84
7 changed files with 504 additions and 165 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Controller\Dashboard;
use App\Entity\AuditLog;
use App\Repository\AccountRepository;
use App\Repository\AuditLogRepository;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
@@ -21,21 +22,34 @@ class LogsController extends AbstractController
#[Route(path: '/crm/logs', name: 'app_crm_audit_logs', methods: ['GET'])]
public function crmLogs(
AuditLogRepository $auditLogRepository,
AccountRepository $userRepository,
PaginatorInterface $paginator,
Request $request
): Response {
$query = $auditLogRepository->getQueryForUser($this->getUser());
// On récupère l'ID du compte à filtrer depuis l'URL (?account=12)
$filterAccountId = $request->query->get('account');
// On appelle la méthode du repository avec l'utilisateur actuel ET le filtre
$query = $auditLogRepository->getQueryForUser($this->getUser(), (int)$filterAccountId);
// Extraction Excel (qui respectera le filtre et la sécurité ROOT)
if ($request->query->get('extract')) {
return $this->generateExcelExport($query->getResult());
}
$pagination = $paginator->paginate(
$query, /* la requête */
$request->query->getInt('page', 1), /* numéro de page */
25 /* nombre d'éléments par page */
$query,
$request->query->getInt('page', 1),
25
);
$users = $userRepository->findAdmin();
if($this->isGranted('ROLE_ROOT')) {
$users = $userRepository->findAll();
}
return $this->render('dashboard/audit_logs.twig', [
'logs' => $pagination // On passe l'objet pagination à la place de l'array
'logs' => $pagination,
'users_list' => $users // Liste pour ton select Twig
]);
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Controller\Dashboard;
use App\Form\AccountPasswordType;
use App\Logger\AppLogger;
use App\Service\Mailer\Mailer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
// ... imports identiques
class ProfilsController extends AbstractController
{
#[Route(path: '/crm/profils', name: 'app_crm_profils', methods: ['GET', 'POST'])]
public function crmProfils(
Request $request,
EntityManagerInterface $em,
UserPasswordHasherInterface $passwordHasher,
Mailer $mailer,
AppLogger $appLogger,
TokenGeneratorInterface $tokenGenerator
): Response {
/** @var \App\Entity\User $user */
$user = $this->getUser();
// --- PROTECTION : REDIRECTION SI ROLE_ROOT ---
if ($this->isGranted('ROLE_ROOT')) {
return $this->redirectToRoute('app_crm');
}
$action = $request->query->get('act');
// --- LOGIQUE 2FA : DÉSACTIVATION ---
if ($action === 'disable2fa') {
if ($user->getGoogleAuthenticatorSecret()) {
$user->setGoogleAuthenticatorSecret(null);
$em->flush();
$mailer->send(
$user->getEmail(),
$user->getFirstName() . " " . $user->getName(),
"[Alerte Sécurité] Désactivation de votre double authentification",
"mails/account/2fa-disable.twig",
['account' => $user, 'who' => $user]
);
$appLogger->record('2FA_DISABLED', sprintf(
"L'utilisateur %s %s a désactivé son propre 2FA (ID: %d)",
$user->getFirstName(), $user->getName(), $user->getId()
));
$this->addFlash('warning', "Votre protection 2FA a été désactivée.");
return $this->redirectToRoute('app_crm_profils');
}
}
// --- LOGIQUE 2FA : ENVOI DU LIEN D'ACTIVATION ---
if ($action === 'sendLink2faenable') {
$token = $tokenGenerator->generateToken();
$user->setConfirmationToken($token);
$setupUrl = $this->generateUrl('app_2fa_setup_confirm', [
'id' => $user->getId(),
'token' => $token
], UrlGeneratorInterface::ABSOLUTE_URL);
$em->flush();
$mailer->send(
$user->getEmail(),
$user->getFirstName() . " " . $user->getName(),
"[Sécurité] Configuration de votre double authentification (2FA)",
"mails/account/2fa-invite.twig",
[
'account' => $user,
'setup_url' => $setupUrl,
'who' => $user,
'expires_at' => (new \DateTime('+1 hour'))->format('H:i')
]
);
$appLogger->record('2FA_INVITE', sprintf(
"Invitation 2FA auto-générée par %s %s",
$user->getFirstName(), $user->getName()
));
$this->addFlash('success', "Un lien de configuration vous a été envoyé par mail.");
return $this->redirectToRoute('app_crm_profils');
}
// --- LOGIQUE MOT DE PASSE ---
$formPassword = $this->createForm(AccountPasswordType::class, $user);
$formPassword->handleRequest($request);
if ($formPassword->isSubmitted() && $formPassword->isValid()) {
$plainPassword = $formPassword->get('plainPassword')->getData();
$user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
$em->flush();
$appLogger->record('PASSWORD_UPDATE', sprintf(
"L'utilisateur %s %s a mis à jour son mot de passe",
$user->getFirstName(), $user->getName()
));
$this->addFlash('success', 'Mot de passe mis à jour avec succès.');
return $this->redirectToRoute('app_crm_profils');
}
// --- LOG DE CONSULTATION ---
$appLogger->record('VIEW', 'Consultation de son profil');
return $this->render('dashboard/profil.twig', [
'user' => $user,
'formPassword' => $formPassword->createView(),
]);
}
}

View File

@@ -51,4 +51,6 @@ class AccountRepository extends ServiceEntityRepository implements PasswordUpgra
}
}

View File

@@ -41,17 +41,29 @@ class AuditLogRepository extends ServiceEntityRepository
// src/Repository/AuditLogRepository.php
public function getQueryForUser(?UserInterface $user)
/**
* Récupère la requête des logs en appliquant les restrictions de sécurité
* et le filtrage optionnel par compte.
*/
public function getQueryForUser(?UserInterface $user, ?int $filterAccount = null): \Doctrine\ORM\Query
{
$qb = $this->createQueryBuilder('l')
->innerJoin('l.account', 'a')
->addSelect('a') // Optimisation pour éviter les requêtes N+1
->orderBy('l.actionAt', 'DESC');
// 1. SÉCURITÉ : Si l'utilisateur n'est pas ROOT, on cache les logs des ROOT
if (!$user || !in_array('ROLE_ROOT', $user->getRoles())) {
$qb->andWhere('a.roles NOT LIKE :role')
->setParameter('role', '%ROLE_ROOT%');
}
// 2. FILTRAGE : Si un compte spécifique est sélectionné dans le filtre
if ($filterAccount) {
$qb->andWhere('a.id = :filterId')
->setParameter('filterId', $filterAccount);
}
return $qb->getQuery();
}
}

View File

@@ -1,12 +1,13 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Traçabilité des actions{% endblock %}
{% block title_header %}Journal d' <span class="text-blue-500">Audit</span>{% endblock %}
{# HEADER : ACTIONS GLOBALES #}
{% 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, account: app.request.query.get('account')}) }}" 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">
<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>
@@ -16,164 +17,185 @@
{% endblock %}
{% 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="space-y-6">
{# 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>
<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 sécurisé par signature cryptographique</p>
</div>
<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">
{{ logs.getTotalItemCount }} ENREGISTREMENTS
</span>
</div>
{# BARRE DE FILTRES #}
{# BARRE DE FILTRES #}
<div class="backdrop-blur-xl bg-white/5 border border-white/5 p-6 rounded-[2rem] shadow-sm mb-8">
<form method="GET" action="{{ path('app_crm_audit_logs') }}" class="flex flex-col md:flex-row items-end gap-5">
{# 1. SELECT COMPTE #}
<div class="flex-1 min-w-[240px]">
<label for="account" class="block text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] mb-2 ml-2">Filtrer par compte</label>
<div class="relative">
<select name="account" id="account" class="w-full bg-slate-900/50 border border-white/10 rounded-xl px-4 py-3.5 text-sm text-white focus:border-blue-500 focus:ring-0 outline-none transition-all appearance-none cursor-pointer">
<option value="">Tous les utilisateurs</option>
{% for u in users_list %}
<option value="{{ u.id }}" {{ app.request.query.get('account') == u.id ? 'selected' : '' }}>
{{ u.firstName }} {{ u.name }}
</option>
{% endfor %}
</select>
<div class="absolute inset-y-0 right-4 flex items-center pointer-events-none text-slate-500">
<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 9l-7 7-7-7"/></svg>
</div>
</div>
</div>
{# 2. BOUTON SUBMIT (APPLIQUER) #}
<div class="w-full md:w-auto">
<button type="submit" class="w-full md:w-auto flex items-center justify-center space-x-2 px-8 py-3.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl shadow-lg shadow-blue-600/20 transition-all duration-300 group">
<svg class="w-4 h-4 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<span class="text-[10px] font-black uppercase tracking-[0.2em]">Filtrer</span>
</button>
</div>
{# 3. BOUTON RESET (Si un filtre est actif) #}
{% if app.request.query.get('account') %}
<div class="w-full md:w-auto">
<a href="{{ path('app_crm_audit_logs') }}" class="flex items-center justify-center px-4 py-3.5 text-slate-400 hover:text-rose-500 text-[10px] font-black uppercase tracking-widest transition-colors group">
<svg class="w-4 h-4 mr-2 group-hover:rotate-90 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
Réinitialiser
</a>
</div>
{% endif %}
</form>
</div>
{# TABLEAU DES LOGS #}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] shadow-2xl overflow-hidden">
<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]">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">Intégrité</th>
<th class="px-8 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em]">Détails</th>
{# HEADER TABLEAU #}
<div class="px-8 py-6 border-b border-white/5 flex items-center justify-between bg-white/5">
<div>
<h2 class="text-xl font-bold text-white tracking-tight">Historique d'activité</h2>
<p class="text-[10px] text-slate-500 font-bold uppercase tracking-widest mt-1">Sécurisé par signature cryptographique SHA-256</p>
</div>
<div class="flex items-center">
<span class="px-4 py-1.5 bg-blue-500/10 text-blue-400 text-[10px] font-black uppercase rounded-lg border border-blue-500/20">
{{ logs.getTotalItemCount }} ENREGISTREMENTS
</span>
</div>
</div>
{# 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>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
{% for log in logs %}
<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. ADMIN & UA #}
<td class="px-8 py-4 whitespace-nowrap align-top">
<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">
{{ log.account.firstName|first|upper }}{{ log.account.name|first|upper }}
</div>
<div class="ml-4">
<div class="text-sm font-bold text-slate-800 dark:text-white flex items-center mb-0.5">
{{ log.account.firstName }} {{ log.account.name }}
{% 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 border border-red-200 dark:border-red-900/50">Root</span>
{% endif %}
</div>
<div class="text-[11px] text-slate-400 dark:text-slate-500 mb-2">{{ log.account.email }}</div>
{% 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-2 py-1.5 rounded-lg border border-slate-200/50 max-w-[260px]">
<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>
{% endif %}
</div>
</div>
</td>
{# 3. TYPE D'ACTION TRADUIT #}
{# 3. TYPE D'ACTION TRADUIT ET STYLISÉ #}
<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' },
'2FA_INVITE': { 'label': 'INVITATION 2FA', 'style': 'bg-indigo-500/10 text-indigo-500 border-indigo-500/20' },
'2FA_DISABLED': { 'label': '2FA DÉSACTIVÉE', 'style': 'bg-rose-500/10 text-rose-600 border-rose-500/20 font-bold' },
'UPDATE_STATUS': { 'label': 'STATUT COMPTE', 'style': 'bg-slate-500/10 text-slate-500 border-slate-500/20' },
'UPDATE_ROLES': { 'label': 'DROITS ACCÈS', 'style': 'bg-purple-500/10 text-purple-600 border-purple-500/20' }
} %}
{# On récupère la configuration ou on utilise une valeur par défaut #}
{% set config = typeMapping[log.type] ?? { 'label': log.type|replace({'_': ' '}), '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É (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>
{# 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">{{ log.path }}</code>
</div>
</td>
{# 6. CELLULE ACTIONS VISIBLE UNIQUEMENT POUR ROOT #}
<div class="overflow-x-auto custom-scrollbar">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-900/40 border-b border-white/5">
<th class="px-8 py-5 text-[11px] font-black text-slate-500 uppercase tracking-[0.2em]">Horodatage</th>
<th class="px-8 py-5 text-[11px] font-black text-slate-500 uppercase tracking-[0.2em]">Agent & Session</th>
<th class="px-8 py-5 text-[11px] font-black text-slate-500 uppercase tracking-[0.2em] text-center">Action</th>
<th class="px-8 py-5 text-[11px] font-black text-slate-500 uppercase tracking-[0.2em] text-center">Intégrité</th>
<th class="px-8 py-5 text-[11px] font-black text-slate-500 uppercase tracking-[0.2em]">Détails</th>
{% 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>
<th class="px-8 py-5 text-[11px] font-black text-slate-500 uppercase tracking-[0.2em] text-right">Admin</th>
{% endif %}
</tr>
{% else %}
<tr>
{# On ajuste le colspan dynamiquement #}
<td colspan="{{ is_granted('ROLE_ROOT') ? 6 : 5 }}" class="px-8 py-12 text-center italic text-slate-400">
Aucun enregistrement.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</thead>
<tbody class="divide-y divide-white/5">
{% for log in logs %}
<tr class="hover:bg-white/5 transition-colors duration-150 group">
{# 1. DATE #}
<td class="px-8 py-6 whitespace-nowrap align-top">
<div class="text-sm font-bold text-white">{{ log.actionAt|date('d/m/Y') }}</div>
<div class="text-[10px] text-slate-500 font-mono mt-0.5 tracking-wider">{{ log.actionAt|date('H:i:s') }}</div>
</td>
{# 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="flex flex-col md:flex-row items-center justify-between gap-6">
<div class="text-xs font-semibold text-slate-400 uppercase tracking-widest">
Page {{ logs.getCurrentPageNumber }}{{ logs|length }} logs affichés
</div>
<div class="navigation shadow-sm rounded-xl overflow-hidden">
{{ knp_pagination_render(logs) }}
{# 2. ADMIN & UA #}
<td class="px-8 py-6 whitespace-nowrap align-top">
<div class="flex items-start">
<div class="h-10 w-10 rounded-xl bg-blue-600 flex flex-shrink-0 items-center justify-center text-white font-black text-xs shadow-lg shadow-blue-600/20 group-hover:scale-110 transition-transform">
{{ log.account.firstName|first|upper }}{{ log.account.name|first|upper }}
</div>
<div class="ml-4">
<div class="text-sm font-bold text-white flex items-center mb-0.5">
{{ log.account.firstName }} {{ log.account.name }}
{% if 'ROLE_ROOT' in log.account.roles %}
<span class="ml-2 px-1.5 py-0.5 rounded text-[8px] bg-rose-500/10 text-rose-500 font-black uppercase border border-rose-500/20">Root</span>
{% endif %}
</div>
<div class="text-[10px] text-slate-500 font-medium truncate max-w-[180px]">{{ log.userAgent|default('Agent inconnu') }}</div>
</div>
</div>
</td>
{# 3. TYPE D'ACTION (Mapping INFO inclus) #}
<td class="px-8 py-6 whitespace-nowrap text-center align-top">
{% set typeMapping = {
'CREATE': { 'label': 'CRÉATION', 'style': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' },
'DELETE': { 'label': 'SUPPRESSION', 'style': 'bg-rose-500/10 text-rose-500 border-rose-500/20' },
'UPDATE': { 'label': 'MODIFICATION', 'style': 'bg-blue-500/10 text-blue-500 border-blue-500/20' },
'AUTH': { 'label': 'CONNEXION', 'style': 'bg-amber-500/10 text-amber-500 border-amber-500/20' },
'VIEW': { 'label': 'CONSULTATION', 'style': 'bg-sky-500/10 text-sky-500 border-sky-500/20' },
'INFO': { 'label': 'INFO', 'style': 'bg-slate-500/10 text-slate-400 border-slate-500/20' },
'SECURITY_ALERT': { 'label': 'ALERTE', 'style': 'bg-orange-500/10 text-orange-500 border-orange-500/20' },
'SECURITY_CRITICAL': { 'label': 'CRITIQUE', 'style': 'bg-red-600 text-white border-red-700 animate-pulse font-black' },
'2FA_INVITE': { 'label': '2FA INVITE', 'style': 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20' },
'2FA_DISABLED': { 'label': '2FA OFF', 'style': 'bg-rose-500/10 text-rose-500 border-rose-500/20 font-bold' }
} %}
{% set config = typeMapping[log.type] ?? { 'label': log.type, 'style': 'bg-slate-500/10 text-slate-500 border-white/10' } %}
<span class="px-3 py-1.5 rounded-lg text-[9px] font-black border uppercase tracking-[0.15em] {{ config.style }}">
{{ config.label }}
</span>
</td>
{# 4. INTÉGRITÉ #}
<td class="px-8 py-6 whitespace-nowrap align-top text-center">
{% if log.hashCode == log.generateSignature %}
<div class="inline-flex flex-col items-center text-emerald-500" title="Signature Valide">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
<span class="text-[7px] font-black uppercase mt-1">Scellé</span>
</div>
{% else %}
<div class="inline-flex flex-col items-center text-rose-500 animate-bounce" title="Données corrompues !">
<svg class="w-5 h-5" 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>
<span class="text-[7px] font-black uppercase mt-1">Altéré</span>
</div>
{% endif %}
</td>
{# 5. DÉTAILS #}
<td class="px-8 py-6 align-top">
<div class="text-sm text-slate-300 font-medium leading-relaxed max-w-lg mb-2">{{ log.message }}</div>
<code class="text-[9px] bg-black/40 px-2 py-1 rounded text-blue-400 font-mono border border-white/5">{{ log.path }}</code>
</td>
{# 6. ACTIONS ROOT #}
{% if is_granted('ROLE_ROOT') %}
<td class="px-8 py-6 whitespace-nowrap text-right align-top">
<form action="{{ path('app_crm_audit_logs_delete', {id: log.id}) }}" method="POST" onsubmit="return confirm('Confirmer la suppression définitive de cet enregistrement ?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ log.id) }}">
<button type="submit" class="p-2 text-slate-500 hover:text-rose-500 hover:bg-rose-500/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="6" class="px-8 py-20 text-center italic text-slate-500 font-medium">
Aucun enregistrement trouvé dans le journal d'audit.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# PAGINATION #}
<div class="px-8 py-8 bg-black/20 border-t border-white/5">
<div class="flex flex-col md:flex-row items-center justify-between gap-6">
<div class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">
Page {{ logs.getCurrentPageNumber }} sur {{ (logs.getTotalItemCount / 25)|round(0, 'ceil') }}
</div>
<div class="navigation custom-pagination">
{{ knp_pagination_render(logs) }}
</div>
</div>
</div>
</div>

View File

@@ -88,14 +88,29 @@
</div>
{# Profil utilisateur #}
<div class="flex items-center space-x-3 px-4 py-2 bg-slate-50 dark:bg-slate-900/50 rounded-2xl border border-slate-200 dark:border-slate-700 shrink-0">
<div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold text-xs">
{{ app.user.firstName|first|upper }}
</div>
<div class="text-left hidden sm:block">
<p class="text-xs font-bold text-slate-800 dark:text-white leading-none mb-1">{{ app.user.firstName }}</p>
<a href="{{ path('app_logout') }}" class="text-[9px] text-red-500 font-bold uppercase tracking-tighter hover:text-red-600">Déconnexion</a>
</div>
<div class="flex items-center gap-2">
{# Zone Profil cliquable #}
<a href="{{ path('app_crm_profils') }}"
class="flex items-center space-x-3 px-4 py-2 bg-slate-50 dark:bg-slate-900/50 rounded-2xl border border-slate-200 dark:border-slate-700 shrink-0 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all group">
<div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold text-xs shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
{{ app.user.firstName|first|upper }}
</div>
<div class="text-left hidden sm:block">
<p class="text-xs font-bold text-slate-800 dark:text-white leading-none mb-0.5">{{ app.user.firstName }}</p>
<span class="text-[8px] text-blue-500 font-bold uppercase tracking-widest opacity-70 group-hover:opacity-100 transition-opacity">Mon Compte</span>
</div>
</a>
{# Bouton Déconnexion séparé #}
<a href="{{ path('app_logout') }}"
title="Déconnexion"
class="w-10 h-10 flex items-center justify-center bg-red-500/10 hover:bg-red-500 text-red-500 hover:text-white border border-red-500/20 rounded-xl transition-all duration-300">
<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.5" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</a>
</div>
</header>

View File

@@ -0,0 +1,149 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Mon Profil{% endblock %}
{% block title_header %}Paramètres du <span class="text-blue-500">Compte</span>{% endblock %}
{% block body %}
<div class="relative min-h-screen">
{# Orbes décoratifs en arrière-plan pour révéler le glassmorphism #}
<div class="absolute -top-24 -left-20 w-72 h-72 bg-blue-600/10 rounded-full blur-[120px] -z-10 pointer-events-none"></div>
<div class="absolute bottom-20 right-0 w-96 h-96 bg-indigo-600/5 rounded-full blur-[130px] -z-10 pointer-events-none"></div>
<div class="max-w-5xl space-y-8 relative z-10">
{# 1. CARTE PROFIL PRINCIPALE (IDENTITÉ) #}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-10 shadow-2xl">
<div class="flex flex-col md:flex-row items-center gap-10">
{# Avatar #}
<div class="relative group">
<div class="w-32 h-32 rounded-[2.5rem] bg-blue-600 flex items-center justify-center text-white text-5xl font-black shadow-2xl shadow-blue-500/20 group-hover:rotate-3 transition-transform duration-500">
{{ user.firstName|first|upper }}
</div>
<div class="absolute -bottom-2 -right-2 w-10 h-10 bg-emerald-500 border-4 border-[#1e293b] rounded-full shadow-lg" title="Compte Actif"></div>
</div>
{# Infos textuelles #}
<div class="flex-1 text-center md:text-left">
<div class="flex flex-wrap items-center justify-center md:justify-start gap-3 mb-2">
<h2 class="text-3xl font-black text-white uppercase tracking-tighter">
{{ user.firstName }} {{ user.name|default('') }}
</h2>
<span class="px-3 py-1 bg-blue-500/10 border border-blue-500/20 rounded-lg text-blue-400 text-[10px] font-black uppercase tracking-widest">
{{ user.roles[0]|replace({'ROLE_': ''}) }}
</span>
</div>
<p class="text-slate-400 font-medium mb-6">{{ user.email }}</p>
<div class="flex flex-wrap justify-center md:justify-start gap-4">
<div class="bg-white/5 border border-white/5 px-4 py-2 rounded-xl text-center">
<p class="text-[9px] text-slate-500 uppercase font-black tracking-widest mb-1">Identifiant</p>
<p class="text-xs font-mono text-white">#{{ user.id }}</p>
</div>
<div class="bg-white/5 border border-white/5 px-4 py-2 rounded-xl text-center">
<p class="text-[9px] text-slate-500 uppercase font-black tracking-widest mb-1">Dernière connexion</p>
<p class="text-xs text-white uppercase font-bold tracking-tighter">Aujourd'hui</p>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
{# 2. COLONNE GAUCHE : FORMULAIRE SÉCURITÉ #}
<div class="backdrop-blur-xl bg-white/5 border border-white/5 p-8 rounded-[2.5rem] shadow-xl">
<div class="flex items-center space-x-4 mb-8">
<div class="w-12 h-12 bg-blue-600/10 rounded-2xl flex items-center justify-center text-blue-500 border border-blue-500/20">
<svg class="w-6 h-6" 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>
</div>
<div>
<h3 class="text-white font-bold text-lg">Sécurité</h3>
<p class="text-slate-500 text-[10px] uppercase font-bold tracking-[0.2em]">Changer le mot de passe</p>
</div>
</div>
{{ form_start(formPassword) }}
<div class="space-y-5 password-form-custom">
{% for row in formPassword %}
{% if row.vars.name != '_token' %}
<div class="space-y-1">
{{ form_label(row, null, {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-2'}}) }}
{{ form_widget(row, {'attr': {'class': 'w-full px-5 py-4 bg-slate-900/50 border border-white/10 rounded-2xl text-white text-sm focus:border-blue-600 focus:ring-0 transition-all outline-none placeholder:text-slate-700'}}) }}
<div class="text-red-500 text-[9px] font-bold uppercase tracking-tighter mt-1 ml-2">
{{ form_errors(row) }}
</div>
</div>
{% endif %}
{% endfor %}
<button type="submit" class="w-full mt-4 py-4 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black uppercase tracking-[0.3em] rounded-2xl shadow-lg shadow-blue-600/20 transition-all duration-300">
Mettre à jour les accès
</button>
</div>
{{ form_end(formPassword) }}
</div>
{# 3. COLONNE DROITE : 2FA & SESSION #}
<div class="space-y-8">
{# AUTHENTIFICATION 2FA #}
<div class="backdrop-blur-xl bg-white/5 border border-white/5 p-8 rounded-[2.5rem] shadow-xl group transition-all duration-500">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center space-x-4">
<div class="h-12 w-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center text-indigo-500 border border-indigo-500/20 group-hover:scale-110 transition-transform duration-500">
<svg class="w-6 h-6" 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>
</div>
<div>
<h4 class="text-white font-bold text-lg tracking-tight">Authentification 2FA</h4>
<p class="text-[10px] uppercase font-bold tracking-[0.2em] mt-1">
{% if user.googleAuthenticatorSecret %}
<span class="text-emerald-500 animate-pulse">● Activée</span>
{% else %}
<span class="text-rose-500">○ Désactivée</span>
{% endif %}
</p>
</div>
</div>
</div>
<p class="text-slate-400 text-xs mb-8 leading-relaxed font-medium">
Ajoutez une couche de protection en utilisant une application comme Google Authenticator pour valider vos connexions.
</p>
<a href="{{ path('app_crm_profils', {act: user.googleAuthenticatorSecret ? 'disable2fa' : 'sendLink2faenable'}) }}"
class="flex items-center justify-center w-full py-4 {% if user.googleAuthenticatorSecret %}bg-white/5 hover:bg-rose-600 text-slate-300 hover:text-white border-white/10 hover:border-rose-600{% else %}bg-indigo-600 hover:bg-indigo-700 text-white shadow-lg shadow-indigo-600/20 border-transparent{% endif %} border rounded-2xl text-[10px] font-black uppercase tracking-[0.3em] transition-all duration-300">
<span>{{ user.googleAuthenticatorSecret ? 'Désactiver la protection' : 'Envoyer le lien d\'activation' }}</span>
</a>
</div>
{# FERMETURE DE SESSION #}
<div class="backdrop-blur-xl bg-rose-500/5 border border-rose-500/10 p-8 rounded-[2.5rem] hover:bg-rose-500/10 transition-colors group">
<div class="w-12 h-12 bg-rose-500/10 rounded-2xl flex items-center justify-center text-rose-500 mb-6 group-hover:scale-110 transition-transform">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
</div>
<h3 class="text-white font-bold text-lg mb-2">Session Intranet</h3>
<p class="text-slate-400 text-sm mb-6 leading-relaxed">Déconnectez-vous pour fermer vos accès sur cet appareil et sécuriser vos données.</p>
<a href="{{ path('app_logout') }}" class="block text-center w-full py-4 bg-rose-500/20 hover:bg-rose-600 text-rose-500 hover:text-white text-[10px] font-black uppercase tracking-[0.3em] rounded-2xl border border-rose-500/20 transition-all">
Se déconnecter
</a>
</div>
</div>
</div>
</div>
</div>
<style>
.password-form-custom ul {
@apply text-red-500 text-[9px] font-bold uppercase tracking-tighter mt-1 ml-2;
}
.backdrop-blur-xl {
background-image: radial-gradient(rgba(255,255,255,0.02) 1px, transparent 0);
background-size: 8px 8px;
}
</style>
{% endblock %}