```
✨ feat(security): Ajoute l'authentification à deux facteurs (2FA) avec Google Authenticator.
```
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"doctrine/doctrine-migrations-bundle": "^3.7.0",
|
||||
"doctrine/orm": "^3.6.1",
|
||||
"docusealco/docuseal-php": "^1.0.5",
|
||||
"endroid/qr-code": ">=6.0.9",
|
||||
"endroid/qr-code": "^6.0",
|
||||
"exbil/mailcow-php-api": ">=0.15.0",
|
||||
"fpdf/fpdf": ">=1.86.1",
|
||||
"google/apiclient": "^2.19.0",
|
||||
@@ -43,7 +43,9 @@
|
||||
"phpoffice/phpspreadsheet": "^5.4",
|
||||
"phpstan/phpdoc-parser": "^2.3.1",
|
||||
"presta/sitemap-bundle": "^4.2",
|
||||
"scheb/2fa-backup-code": "^7.13",
|
||||
"scheb/2fa-bundle": "^7.13.1",
|
||||
"scheb/2fa-email": "^7.13",
|
||||
"scheb/2fa-google-authenticator": "^7.13.1",
|
||||
"sentry/sentry-symfony": "^5.8.3",
|
||||
"setasign/fpdi": "^2.6.4",
|
||||
|
||||
103
composer.lock
generated
103
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9c1f1c501a3764fad79d4fb69f99c1eb",
|
||||
"content-hash": "6cc6334636ac6b9f69ebd4f4736d69f4",
|
||||
"packages": [
|
||||
{
|
||||
"name": "async-aws/core",
|
||||
@@ -8008,6 +8008,55 @@
|
||||
],
|
||||
"time": "2025-12-02T15:19:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "scheb/2fa-backup-code",
|
||||
"version": "v7.13.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/scheb/2fa-backup-code.git",
|
||||
"reference": "35f1ace4be7be2c10158d2bb8284208499111db8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/scheb/2fa-backup-code/zipball/35f1ace4be7be2c10158d2bb8284208499111db8",
|
||||
"reference": "35f1ace4be7be2c10158d2bb8284208499111db8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"scheb/2fa-bundle": "self.version"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Scheb\\TwoFactorBundle\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Scheb",
|
||||
"email": "me@christianscheb.de"
|
||||
}
|
||||
],
|
||||
"description": "Extends scheb/2fa-bundle with backup codes support",
|
||||
"homepage": "https://github.com/scheb/2fa",
|
||||
"keywords": [
|
||||
"2fa",
|
||||
"Authentication",
|
||||
"backup-codes",
|
||||
"symfony",
|
||||
"two-factor",
|
||||
"two-step"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/scheb/2fa-backup-code/tree/v7.13.1"
|
||||
},
|
||||
"time": "2025-11-20T13:35:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "scheb/2fa-bundle",
|
||||
"version": "v7.13.1",
|
||||
@@ -8076,6 +8125,58 @@
|
||||
},
|
||||
"time": "2025-12-18T15:29:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "scheb/2fa-email",
|
||||
"version": "v7.13.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/scheb/2fa-email.git",
|
||||
"reference": "ee70a062dde6aa9d566f99d2b1ae40b544ddbcef"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/scheb/2fa-email/zipball/ee70a062dde6aa9d566f99d2b1ae40b544ddbcef",
|
||||
"reference": "ee70a062dde6aa9d566f99d2b1ae40b544ddbcef",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"scheb/2fa-bundle": "self.version"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/mailer": "Needed if you want to use the default mailer implementation"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Scheb\\TwoFactorBundle\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Scheb",
|
||||
"email": "me@christianscheb.de"
|
||||
}
|
||||
],
|
||||
"description": "Extends scheb/2fa-bundle with two-factor authentication via email",
|
||||
"homepage": "https://github.com/scheb/2fa",
|
||||
"keywords": [
|
||||
"2fa",
|
||||
"Authentication",
|
||||
"email",
|
||||
"symfony",
|
||||
"two-factor",
|
||||
"two-step"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/scheb/2fa-email/tree/v7.13.1"
|
||||
},
|
||||
"time": "2025-11-20T13:35:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "scheb/2fa-google-authenticator",
|
||||
"version": "v7.13.1",
|
||||
|
||||
@@ -3,3 +3,13 @@ scheb_two_factor:
|
||||
security_tokens:
|
||||
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
|
||||
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
|
||||
backup_codes:
|
||||
enabled: true
|
||||
email:
|
||||
enabled: true
|
||||
google:
|
||||
enabled: true
|
||||
server_name: "Intranet Ludikevent"
|
||||
issuer: "Ludikevent"
|
||||
digits: 6
|
||||
leeway: 0
|
||||
|
||||
@@ -19,6 +19,7 @@ security:
|
||||
two_factor:
|
||||
auth_form_path: 2fa_login # Route d'affichage du formulaire
|
||||
check_path: 2fa_login_check # Route de soumission du code
|
||||
default_target_path: /
|
||||
# -------------------------------------
|
||||
|
||||
form_login:
|
||||
|
||||
32
migrations/Version20260116085657.php
Normal file
32
migrations/Version20260116085657.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 Version20260116085657 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE account ADD confirmation_token VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
$this->addSql('ALTER TABLE "account" DROP confirmation_token');
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ 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;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
class AccountController extends AbstractController
|
||||
@@ -91,7 +93,8 @@ class AccountController extends AbstractController
|
||||
AccountLoginRegisterRepository $accountLoginRegisterRepository,
|
||||
EntityManagerInterface $em,
|
||||
AuditLogRepository $auditLogRepository,
|
||||
UserPasswordHasherInterface $passwordHasher
|
||||
UserPasswordHasherInterface $passwordHasher,
|
||||
TokenGeneratorInterface $tokenGenerator
|
||||
): Response {
|
||||
if (!$account) {
|
||||
$this->addFlash('error', 'Administrateur introuvable.');
|
||||
@@ -106,15 +109,77 @@ class AccountController extends AbstractController
|
||||
);
|
||||
$this->sendSecurityEmail($mailer, $title, $details);
|
||||
$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');
|
||||
}
|
||||
if ($request->query->get('act') === 'disable2fa') {
|
||||
if($account->isGoogleAuthenticatorEnabled()) {
|
||||
$account->setGoogleAuthenticatorSecret(null);
|
||||
$em->flush();
|
||||
|
||||
// Récupération de l'admin qui fait l'action
|
||||
$currentUser = $this->getUser();
|
||||
|
||||
$mailer->send(
|
||||
$account->getEmail(),
|
||||
$account->getFirstName() . " " . $account->getName(),
|
||||
"[Alerte Sécurité] Désactivation de votre double authentification",
|
||||
"mails/account/2fa-disable.twig",
|
||||
[
|
||||
'account' => $account,
|
||||
'who' => $currentUser // On passe l'objet admin connecté
|
||||
]
|
||||
);
|
||||
|
||||
$logMessage = sprintf(
|
||||
"2FA désactivée pour %s %s par %s %s (ID: %d)",
|
||||
$account->getFirstName(), $account->getName(),
|
||||
$currentUser->getFirstName(), $currentUser->getName(),
|
||||
$account->getId()
|
||||
);
|
||||
$appLogger->record('2FA_DISABLED', $logMessage);
|
||||
|
||||
$this->addFlash('warning', "La protection 2FA de " . $account->getFirstName() . " a été retirée.");
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->query->get('act') === 'sendLink2faenable') {
|
||||
$currentUser = $this->getUser();
|
||||
|
||||
$account->setConfirmationTokenName($tokenGenerator->generateToken());
|
||||
$setupUrl = $this->generateUrl('app_2fa_setup_confirm', [
|
||||
'id' => $account->getId(),
|
||||
'token' => $account->getConfirmationToken()
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
$em->persist($account);
|
||||
$em->flush();
|
||||
|
||||
$mailer->send(
|
||||
$account->getEmail(),
|
||||
$account->getFirstName() . " " . $account->getName(),
|
||||
"[Sécurité] Configuration de votre double authentification (2FA)",
|
||||
"mails/account/2fa-invite.twig",
|
||||
[
|
||||
'account' => $account,
|
||||
'setup_url' => $setupUrl,
|
||||
'who' => $currentUser, // On passe l'objet admin connecté
|
||||
'expires_at' => (new \DateTime('+1 hour'))->format('H:i')
|
||||
]
|
||||
);
|
||||
|
||||
$logMessage = sprintf(
|
||||
"Invitation 2FA envoyée à %s %s par %s %s",
|
||||
$account->getFirstName(), $account->getName(),
|
||||
$currentUser->getFirstName(), $currentUser->getName()
|
||||
);
|
||||
$appLogger->record('2FA_INVITE', $logMessage);
|
||||
|
||||
$this->addFlash('success', "Lien envoyé à " . $account->getFirstName());
|
||||
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
|
||||
}
|
||||
|
||||
// --- 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');
|
||||
|
||||
85
src/Controller/TwoController.php
Normal file
85
src/Controller/TwoController.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Account;
|
||||
use App\Logger\AppLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\Encoding\Encoding;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||
use Endroid\QrCode\Label\LabelAlignment;
|
||||
use Endroid\QrCode\RoundBlockSizeMode;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
|
||||
class TwoController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/2fa/configure/{id}/{token}', name: 'app_2fa_setup_confirm', options: ['sitemap' => false], methods: ['GET', 'POST'])]
|
||||
public function index(
|
||||
?Account $account,
|
||||
string $token,
|
||||
GoogleAuthenticatorInterface $googleAuthenticator,
|
||||
EntityManagerInterface $em,
|
||||
Request $request,
|
||||
AppLogger $appLogger
|
||||
): Response {
|
||||
|
||||
if (!$account || $account->getConfirmationToken() !== $token) {
|
||||
return $this->render('security/error_2fa.twig', ['message' => 'Lien invalide.']);
|
||||
}
|
||||
|
||||
// Génération du secret si nécessaire
|
||||
if (!$account->getGoogleAuthenticatorSecret()) {
|
||||
$account->setGoogleAuthenticatorSecret($googleAuthenticator->generateSecret());
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
// Logique de validation
|
||||
if ($request->isMethod('POST')) {
|
||||
$checkCode = $request->request->get('_check_code');
|
||||
if ($googleAuthenticator->checkCode($account, $checkCode)) {
|
||||
$account->setConfirmationTokenName(null);
|
||||
$em->flush();
|
||||
$appLogger->record('AUTH', "2FA activée pour " . $account->getEmail());
|
||||
return $this->render('security/success_2fa.twig');
|
||||
}
|
||||
$this->addFlash('error', 'Code invalide.');
|
||||
}
|
||||
|
||||
// --- GÉNÉRATION DU QR CODE VIA ENDROID ---
|
||||
$qrContent = $googleAuthenticator->getQRContent($account);
|
||||
|
||||
// Chemin vers ton logo (ajuste le chemin si nécessaire)
|
||||
$logoPath = $this->getParameter('kernel.project_dir') . '/public/provider/images/logo.png';
|
||||
|
||||
$builder = new Builder(
|
||||
writer: new PngWriter(),
|
||||
writerOptions: [],
|
||||
validateResult: false,
|
||||
data: $qrContent,
|
||||
encoding: new Encoding('UTF-8'),
|
||||
errorCorrectionLevel: ErrorCorrectionLevel::High,
|
||||
size: 250,
|
||||
margin: 10,
|
||||
roundBlockSizeMode: RoundBlockSizeMode::Margin,
|
||||
// Logo (Optionnel : vérifie si le fichier existe avant)
|
||||
labelAlignment: LabelAlignment::Center,
|
||||
// Label sous le QR Code
|
||||
logoPath: file_exists($logoPath) ? $logoPath : null,
|
||||
logoResizeToWidth: 50, // NotoSans est souvent inclus par défaut
|
||||
logoPunchoutBackground: true
|
||||
);
|
||||
|
||||
$result = $builder->build();
|
||||
|
||||
return $this->render('security/setup_2fa.twig', [
|
||||
'account' => $account,
|
||||
'qrCodeUrl' => $result->getDataUri() // Génère le Base64 pour le <img>
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
@@ -18,7 +19,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_USERNAME', fields: ['username'])]
|
||||
#[UniqueEntity(fields: ['email'], message: 'Cette adresse e-mail est déjà utilisée.')]
|
||||
#[UniqueEntity(fields: ['uuid'], message: 'Cet identifiant unique (UUID) est déjà utilisé.')]
|
||||
class Account implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
class Account implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
@@ -74,6 +75,8 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: AuditLog::class, mappedBy: 'account')]
|
||||
private Collection $auditLogs;
|
||||
#[ORM\Column(type: 'string', nullable: true)]
|
||||
private ?string $confirmationToken;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -350,4 +353,16 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getConfirmationToken(): ?string
|
||||
{
|
||||
return $this->confirmationToken;
|
||||
}
|
||||
|
||||
public function setConfirmationTokenName(?string $token): static
|
||||
{
|
||||
$this->confirmationToken = $token;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,14 @@
|
||||
{% 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="mb-10 flex flex-col md:flex-row md:items-center justify-between gap-6 pb-8 border-b border-slate-200 dark: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">
|
||||
@@ -31,8 +28,7 @@
|
||||
<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">
|
||||
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight">
|
||||
{{ admin.firstName }} {{ admin.name }}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -40,18 +36,17 @@
|
||||
<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>
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ admin.email }}</span>
|
||||
</div>
|
||||
<div class="h-1 w-1 bg-slate-700 rounded-full"></div>
|
||||
<div class="h-1 w-1 bg-slate-300 dark: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 bg-white dark:bg-slate-900 border border-amber-500/30 rounded-2xl overflow-hidden shadow-xl">
|
||||
<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>
|
||||
@@ -62,23 +57,23 @@
|
||||
</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 bg-white dark:bg-slate-900 border border-emerald-500/30 rounded-2xl overflow-hidden shadow-xl">
|
||||
<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">
|
||||
class="px-6 py-3 bg-slate-100 dark:bg-slate-800 hover:bg-red-600 text-slate-500 dark:text-slate-400 hover:text-white text-[10px] font-black uppercase tracking-widest transition-all active:scale-95 border-l border-slate-200 dark: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="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{# --- COLONNE GAUCHE : IDENTITÉ --- #}
|
||||
<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">
|
||||
@@ -101,9 +96,7 @@
|
||||
<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'} }) }}
|
||||
@@ -112,7 +105,6 @@
|
||||
<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">
|
||||
@@ -123,9 +115,9 @@
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
|
||||
{# --- COLONNE DROITE : DROITS & HISTORIQUE --- #}
|
||||
{# --- COLONNE DROITE : SÉCURITÉ & DROITS --- #}
|
||||
<div class="lg:col-span-8 space-y-8">
|
||||
{# --- CARTE SÉCURITÉ & MOT DE PASSE --- #}
|
||||
{# CARTE SÉCURITÉ #}
|
||||
<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">
|
||||
@@ -142,22 +134,46 @@
|
||||
{{ 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>
|
||||
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">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>
|
||||
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Confirmer</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 type="submit" class="px-6 py-3 bg-slate-800 dark:bg-slate-700 text-white text-[10px] font-black uppercase rounded-xl hover:bg-indigo-600 transition-all">
|
||||
Mettre à jour
|
||||
</button>
|
||||
</div>
|
||||
{{ form_end(passwordForm) }}
|
||||
</div>
|
||||
{# HABILITATIONS #}
|
||||
|
||||
{# BLOC 2FA #}
|
||||
<div class="flex items-center justify-between p-6 bg-slate-50 dark:bg-slate-900/40 rounded-3xl border border-slate-200 dark:border-slate-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="h-12 w-12 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-500 border border-indigo-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>
|
||||
<h4 class="text-sm font-bold text-slate-800 dark:text-white">Authentification 2FA</h4>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
{% if admin.googleAuthenticatorSecret %}
|
||||
<span class="text-emerald-500 font-bold">● Activée</span> — Accès hautement sécurisé
|
||||
{% else %}
|
||||
<span class="text-rose-500 font-bold">○ Désactivée</span> — Risque de sécurité
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ path('app_crm_administrateur_view', {id: admin.id, act: admin.googleAuthenticatorSecret ? 'disable2fa' : 'sendLink2faenable'}) }}"
|
||||
class="px-4 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 text-xs font-bold rounded-xl hover:bg-indigo-500 hover:text-white transition-all shadow-sm">
|
||||
<span>{{ admin.googleAuthenticatorSecret ? 'Désactiver' : 'Envoyer le lien' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# HABILITATIONS OPÉRATIONNELLES #}
|
||||
{% 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">
|
||||
@@ -207,12 +223,12 @@
|
||||
</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>
|
||||
<p class="text-emerald-600/70 dark:text-emerald-400/70 text-xs mt-1">Le rang Client Principal accorde nativement toutes les permissions système.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# PRIVILÈGE DE STRUCTURE (ROOT) #}
|
||||
{# PRIVILÈGE 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">
|
||||
@@ -227,8 +243,7 @@
|
||||
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">
|
||||
<div class="p-6 rounded-2xl bg-slate-900 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">
|
||||
@@ -249,71 +264,36 @@
|
||||
</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 #}
|
||||
{# --- SECTION JOURNAUX (AUDIT) --- #}
|
||||
<div class="mt-12 w-full bg-white dark:bg-[#1e293b] rounded-3xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||
<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>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">Historique des actions et vérification d'intégrité numérique</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">
|
||||
<span class="px-4 py-1.5 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400 text-[10px] font-black uppercase rounded-lg border border-indigo-100 dark:border-indigo-800/50">
|
||||
{{ auditLogs.getTotalItemCount }} ÉVÈNEMENTS
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto custom-scrollbar">
|
||||
<div class="overflow-x-auto">
|
||||
<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 class="bg-slate-50/50 dark:bg-slate-900/40 border-b border-slate-100 dark:border-slate-800 uppercase tracking-widest text-[10px] font-black text-slate-400">
|
||||
<th class="px-8 py-4">Horodatage</th>
|
||||
<th class="px-8 py-4 text-center">Action</th>
|
||||
<th class="px-8 py-4 text-center">Intégrité</th>
|
||||
<th class="px-8 py-4">Détails</th>
|
||||
{% if is_granted('ROLE_ROOT') %}<th class="px-8 py-4 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 #}
|
||||
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors">
|
||||
<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' },
|
||||
@@ -322,82 +302,60 @@
|
||||
'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' }
|
||||
'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' }
|
||||
} %}
|
||||
|
||||
{% set config = typeMapping[log.type] ?? { 'label': log.type, 'style': 'bg-slate-500/10 text-slate-500 border-slate-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É #}
|
||||
<td class="px-8 py-4 whitespace-nowrap align-top text-center">
|
||||
<td class="px-8 py-4 text-center align-top">
|
||||
{% 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 class="inline-flex flex-col items-center text-emerald-500" title="Signature HMAC valide">
|
||||
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
||||
<span class="text-[8px] font-black uppercase">Sécurisé</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 class="inline-flex flex-col items-center text-rose-500 animate-pulse" title="ALTÉRATION DÉTECTÉE">
|
||||
<svg class="w-5 h-5 mb-1" 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-[8px] font-black uppercase tracking-tighter">Corrompu</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>
|
||||
<div class="text-xs text-slate-600 dark:text-slate-300 font-medium leading-relaxed max-w-xl">{{ log.message }}</div>
|
||||
<div class="mt-2 text-[9px] font-mono text-slate-400 bg-slate-100 dark:bg-slate-900 px-2 py-0.5 rounded inline-block">ID: {{ log.hashCode|slice(0, 16) }}...</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 ?');">
|
||||
<td class="px-8 py-4 text-right align-top">
|
||||
<form action="{{ path('app_crm_audit_logs_delete', {id: log.id}) }}" method="POST" onsubmit="return confirm('Supprimer définitivement ce log ?');">
|
||||
<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">
|
||||
<button type="submit" class="p-2 text-slate-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-900/10 rounded-xl 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="mt-2 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>
|
||||
@@ -427,7 +385,6 @@
|
||||
</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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -101,11 +101,15 @@
|
||||
'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' }
|
||||
'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, 'style': 'bg-slate-500/10 text-slate-500 border-slate-500/20' } %}
|
||||
{% 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 }}
|
||||
|
||||
39
templates/mails/account/2fa-disable.twig
Normal file
39
templates/mails/account/2fa-disable.twig
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends 'mails/base.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<mj-section>
|
||||
<mj-column background-color="#ffffff" border-radius="20px" padding="40px" css-class="card-shadow warning-border">
|
||||
|
||||
<mj-image width="50px" src="https://cdn-icons-png.flaticon.com/512/159/159478.png" padding-bottom="10px"></mj-image>
|
||||
|
||||
<mj-text align="center" font-size="20px" font-weight="900" color="#1e293b" text-transform="uppercase" letter-spacing="1px">
|
||||
Sécurité du compte
|
||||
</mj-text>
|
||||
|
||||
<mj-text align="center" font-size="15px" padding-top="20px">
|
||||
Bonjour <strong>{{ datas.account.firstName }}</strong>,
|
||||
</mj-text>
|
||||
|
||||
<mj-text align="center" color="#64748b">
|
||||
L'authentification à deux facteurs (2FA) a été <span style="color: #e11d48; font-weight: bold;">désactivée</span> sur votre compte.
|
||||
</mj-text>
|
||||
|
||||
<mj-text align="center" font-size="13px" color="#94a3b8" padding-top="20px">
|
||||
Votre compte n'est désormais protégé que par votre mot de passe habituel.
|
||||
</mj-text>
|
||||
|
||||
<mj-divider border-width="1px" border-color="#f1f5f9" padding="30px 0"></mj-divider>
|
||||
|
||||
{# --- BLOC INFORMATION AUTEUR --- #}
|
||||
<mj-text align="center" font-size="12px" color="#64748b">
|
||||
Action effectuée par l'administrateur :<br/>
|
||||
<span style="color: #1e293b; font-weight: bold;">{{ datas.who.firstName }} {{ datas.who.name }}</span>
|
||||
</mj-text>
|
||||
|
||||
<mj-text align="center" font-size="12px" color="#64748b" font-style="italic" padding-top="20px">
|
||||
S'il ne s'agit pas d'une action prévue, veuillez contacter immédiatement le responsable informatique de Ludikevent.
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
{% endblock %}
|
||||
37
templates/mails/account/2fa-invite.twig
Normal file
37
templates/mails/account/2fa-invite.twig
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends 'mails/base.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<mj-section>
|
||||
<mj-column background-color="#ffffff" border-radius="24px" padding="40px" css-class="shadow">
|
||||
|
||||
<mj-image width="64px" src="https://cdn-icons-png.flaticon.com/512/1162/1162235.png" padding-bottom="20px"></mj-image>
|
||||
|
||||
<mj-text align="center" font-size="24px" font-weight="900" color="#1e293b" text-transform="uppercase" letter-spacing="1px">
|
||||
Sécurisez votre accès
|
||||
</mj-text>
|
||||
|
||||
<mj-text align="center" color="#64748b" font-size="14px">
|
||||
Bonjour {{ datas.account.firstName }},<br/><br/>
|
||||
Pour protéger les données de <strong>Ludikevent</strong>, nous vous invitons à activer l'authentification à deux facteurs (2FA).
|
||||
</mj-text>
|
||||
|
||||
<mj-button background-color="#4f46e5" color="#ffffff" border-radius="12px" font-weight="900" font-size="13px" padding-top="30px" inner-padding="16px 32px" href="{{ datas.setup_url }}" text-transform="uppercase" letter-spacing="1px">
|
||||
Configurer mon 2FA
|
||||
</mj-button>
|
||||
|
||||
<mj-divider border-width="1px" border-color="#f1f5f9" padding="30px 0"></mj-divider>
|
||||
|
||||
{# --- AJOUT DE L'AUTEUR DE L'ACTION --- #}
|
||||
<mj-text align="center" font-size="12px" color="#64748b" padding-bottom="10px">
|
||||
Cette invitation a été générée par l'administrateur :<br/>
|
||||
<span style="color: #4f46e5; font-weight: bold;">{{ datas.who.firstName }} {{ datas.who.name }}</span>
|
||||
</mj-text>
|
||||
|
||||
<mj-text align="center" font-size="11px" color="#94a3b8">
|
||||
Ce lien est temporaire et expirera à <strong>{{ datas.expires_at }}</strong>.<br/>
|
||||
Une fois cliqué, vous devrez scanner un QR Code avec votre application (Google Authenticator ou FreeOTP).
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
{% endblock %}
|
||||
54
templates/security/error_2fa.twig
Normal file
54
templates/security/error_2fa.twig
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends 'base.twig' %}
|
||||
|
||||
{% block title %}Erreur de configuration 2FA - Ludikevent{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-[#F8FAFC] py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full bg-white rounded-[2.5rem] p-10 shadow-[0_20px_50px_rgba(0,0,0,0.05)] border border-gray-100 text-center">
|
||||
|
||||
{# Logo Ludikevent #}
|
||||
<div class="flex justify-center mb-8">
|
||||
<img src="{{ asset('provider/images/logo.png') }}" class="h-12 w-auto" alt="Ludikevent"/>
|
||||
</div>
|
||||
|
||||
{# Icône d'alerte stylisée avec un fond doux #}
|
||||
<div class="w-20 h-20 bg-rose-50 text-rose-500 rounded-[2rem] flex items-center justify-center mx-auto mb-6 shadow-inner">
|
||||
<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="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>
|
||||
|
||||
{# Titre d'erreur #}
|
||||
<h1 class="text-2xl font-black text-slate-900 uppercase tracking-tighter leading-none mb-3">
|
||||
Lien Invalide
|
||||
</h1>
|
||||
|
||||
{# Séparateur visuel #}
|
||||
<div class="h-1 w-8 bg-rose-500 mx-auto mb-6 rounded-full"></div>
|
||||
|
||||
{# Message dynamique #}
|
||||
<p class="text-slate-400 text-sm leading-relaxed mb-10 px-4">
|
||||
{{ message|default("Ce lien de configuration est expiré, corrompu ou a déjà été utilisé pour sécuriser ce compte.") }}
|
||||
<br><br>
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest bg-slate-50 text-slate-500 px-4 py-1.5 rounded-full border border-slate-100">
|
||||
Action de sécurité requise
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{# Boutons d'action #}
|
||||
<div class="space-y-4">
|
||||
<a href="{{ path('app_home') }}"
|
||||
class="block w-full py-4 bg-slate-900 text-white rounded-2xl font-bold hover:bg-indigo-600 hover:scale-[1.02] transition-all shadow-xl shadow-slate-200 uppercase text-xs tracking-[0.2em]">
|
||||
Retour à la connexion
|
||||
</a>
|
||||
|
||||
<div class="pt-4">
|
||||
<p class="text-[10px] text-slate-300 uppercase tracking-widest font-bold">
|
||||
Besoin d'aide ? <a href="mailto:contact@ludikevent.fr" class="text-indigo-400 hover:text-indigo-600 transition-colors">Contactez le support</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
90
templates/security/setup_2fa.twig
Normal file
90
templates/security/setup_2fa.twig
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends 'base.twig' %}
|
||||
|
||||
{% block title %}Configuration Sécurité 2FA - Ludikevent{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-[#F8FAFC] py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full bg-white rounded-[2.5rem] p-10 shadow-[0_20px_50px_rgba(0,0,0,0.05)] border border-gray-100">
|
||||
|
||||
{# Logo et En-tête #}
|
||||
<div class="flex flex-col items-center mb-10">
|
||||
<img src="{{ asset('provider/images/logo.png') }}" class="h-12 w-auto mb-8" alt="Ludikevent"/>
|
||||
|
||||
<div class="inline-flex items-center space-x-2 px-3 py-1 bg-indigo-50 rounded-full mb-4">
|
||||
<span class="w-2 h-2 bg-indigo-500 rounded-full animate-pulse"></span>
|
||||
<span class="text-[10px] font-bold text-indigo-600 uppercase tracking-widest">Sécurisation en cours</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-black text-slate-900 uppercase tracking-tight text-center">
|
||||
Double Authentification
|
||||
</h1>
|
||||
<p class="text-slate-400 text-sm text-center mt-2 px-4">
|
||||
Scannez le code QR pour lier votre compte à une application de sécurité.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Zone QR Code #}
|
||||
<div class="flex flex-col items-center justify-center mb-10">
|
||||
<div class="relative p-6 bg-white border-2 border-dashed border-gray-100 rounded-[2rem]">
|
||||
<img src="{{ qrCodeUrl }}" class="w-44 h-44" alt="QR Code 2FA"/>
|
||||
|
||||
{# Petits coins décoratifs pour simuler un scanner #}
|
||||
<div class="absolute top-4 left-4 w-4 h-4 border-t-2 border-l-2 border-indigo-500 rounded-tl-lg"></div>
|
||||
<div class="absolute top-4 right-4 w-4 h-4 border-t-2 border-r-2 border-indigo-500 rounded-tr-lg"></div>
|
||||
<div class="absolute bottom-4 left-4 w-4 h-4 border-b-2 border-l-2 border-indigo-500 rounded-bl-lg"></div>
|
||||
<div class="absolute bottom-4 right-4 w-4 h-4 border-b-2 border-r-2 border-indigo-500 rounded-br-lg"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 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="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="text-[11px] font-medium italic">Ouvrez Google Authenticator ou FreeOTP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Formulaire de Validation #}
|
||||
<form data-turbo="false" method="post" class="space-y-6">
|
||||
{% for message in app.flashes('error') %}
|
||||
<div class="p-4 bg-rose-50 text-rose-600 text-xs font-bold rounded-2xl border border-rose-100 text-center animate-shake">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div>
|
||||
<label class="block text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 text-center mb-4">
|
||||
Saisissez le code à 6 chiffres
|
||||
</label>
|
||||
<input type="text"
|
||||
name="_check_code"
|
||||
maxlength="6"
|
||||
required
|
||||
placeholder="· · · · · ·"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full bg-slate-50 border-2 border-transparent focus:border-indigo-500 focus:bg-white focus:ring-0 rounded-2xl py-4 text-center text-3xl font-mono tracking-[0.3em] text-slate-800 transition-all outline-none">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full py-4 bg-slate-900 hover:bg-indigo-600 text-white rounded-2xl font-bold uppercase text-xs tracking-[0.2em] shadow-xl shadow-slate-200 transition-all transform hover:scale-[1.02] active:scale-[0.98]">
|
||||
Confirmer l'activation
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<a href="{{ path('app_home') }}" class="text-xs font-bold text-slate-300 hover:text-slate-500 transition-colors uppercase tracking-widest">
|
||||
Annuler la configuration
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
.animate-shake { animation: shake 0.2s ease-in-out 0s 2; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
54
templates/security/success_2fa.twig
Normal file
54
templates/security/success_2fa.twig
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends 'base.twig' %}
|
||||
|
||||
{% block title %}Sécurité Active - Ludikevent{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-[#F8FAFC] py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full bg-white rounded-[2.5rem] p-10 shadow-[0_20px_50px_rgba(0,0,0,0.05)] border border-gray-100 text-center">
|
||||
|
||||
{# Logo Ludikevent #}
|
||||
<div class="flex justify-center mb-8">
|
||||
<img src="{{ asset('provider/images/logo.png') }}" class="h-12 w-auto" alt="Ludikevent"/>
|
||||
</div>
|
||||
|
||||
{# Icône de succès avec animation douce #}
|
||||
<div class="w-20 h-20 bg-emerald-50 text-emerald-500 rounded-[2rem] flex items-center justify-center mx-auto mb-6 shadow-inner animate-bounce">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{# Titre de succès #}
|
||||
<h1 class="text-2xl font-black text-slate-900 uppercase tracking-tighter leading-none mb-3">
|
||||
Sécurité Active
|
||||
</h1>
|
||||
|
||||
{# Séparateur visuel vert #}
|
||||
<div class="h-1 w-8 bg-emerald-500 mx-auto mb-6 rounded-full"></div>
|
||||
|
||||
{# Message de confirmation #}
|
||||
<p class="text-slate-400 text-sm leading-relaxed mb-10 px-4">
|
||||
Félicitations ! Votre authentification à deux facteurs (**2FA**) est désormais configurée avec succès.
|
||||
<br><br>
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest bg-emerald-50 text-emerald-600 px-4 py-1.5 rounded-full border border-emerald-100">
|
||||
Compte protégé
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{# Boutons d'action #}
|
||||
<div class="space-y-4">
|
||||
<a href="{{ path('app_home') }}"
|
||||
class="block w-full py-4 bg-slate-900 text-white rounded-2xl font-bold hover:bg-indigo-600 hover:scale-[1.02] transition-all shadow-xl shadow-slate-200 uppercase text-xs tracking-[0.2em]">
|
||||
Se connecter maintenant
|
||||
</a>
|
||||
|
||||
<div class="pt-4">
|
||||
<p class="text-[10px] text-slate-400 uppercase tracking-widest font-bold">
|
||||
Important : ne supprimez pas votre application Authenticator
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user