feat(ansible): Ajoute des headers de sécurité et limite la taille des requêtes.

 feat(Security): Active l'authentification à deux facteurs (2FA).
 feat(Account): Ajoute une entité et un formulaire pour les administrateurs.
🐛 fix(Security): Corrige la redirection après la connexion.
 feat(CRM): Ajoute une page d'administration des comptes administrateurs.
This commit is contained in:
Serreau Jovann
2026-01-15 18:51:17 +01:00
parent 51c1aa2f6f
commit b1b2687320
22 changed files with 813 additions and 198 deletions

2
.env
View File

@@ -64,7 +64,7 @@ GOOGLE_APPLICATION_CREDENTIALS=%kernel.project_dir%/google.json
###< google/apiclient ###
###> sentry/sentry-symfony ###
SENTRY_DSN=""
SENTRY_DSN="https://5c3a5a5e27365d3db5ca866129ff7600@sentry.esy-web.dev/23"
###< sentry/sentry-symfony ###
DEFAULT_URI=https://esyweb.local
KEYCLOAK_AUTH_SERVER_URL=https://auth.esy-web.dev

View File

@@ -2,14 +2,25 @@ intranet.ludikevent.fr {
tls {
dns cloudflare KL6pZ-Z_12_zbnM2TtFDIsKM8A-HLPhU5GJJbKTW
}
root * {{ path }}/public
root * {{ path }}/public
file_server
request_body {
max_size 100MB
}
header {
# This prevents search engines from indexing the site
X-Robots-Tag "noindex, nofollow, nosnippet, noarchive"
# Your existing Permissions-Policy
Permissions-Policy "accelerometer=(), autoplay=(), camera=(), clipboard-write=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), usb=(), vr=(), screen-wake-lock=(), xr-spatial-tracking=(), bluetooth=(), ambient-light-sensor=(), battery=(), gamepad=(), notifications=(), push=()"
# Recommended security headers for an intranet
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
php_fastcgi unix//run/php/php8.3-fpm.sock {

View File

@@ -1,2 +1,79 @@
import './admin.scss'
import * as Turbo from "@hotwired/turbo"
// Cette fonction initialise tous les écouteurs d'événements
function initAdminLayout() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const toggleBtn = document.getElementById('sidebar-toggle');
const settingsToggle = document.getElementById('settings-toggle');
const settingsSubmenu = document.getElementById('settings-submenu');
const settingsChevron = document.getElementById('settings-chevron');
// --- TOGGLE SIDEBAR MOBILE ---
if (toggleBtn && sidebar && overlay) {
// On clone pour éviter de doubler les events avec Turbo
toggleBtn.replaceWith(toggleBtn.cloneNode(true));
const newToggleBtn = document.getElementById('sidebar-toggle');
newToggleBtn.addEventListener('click', () => {
sidebar.classList.toggle('-translate-x-full');
overlay.classList.toggle('hidden');
});
overlay.addEventListener('click', () => {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('hidden');
});
}
// --- GESTION SOUS-MENU PARAMÈTRES ---
if (settingsToggle) {
settingsToggle.replaceWith(settingsToggle.cloneNode(true));
const newSettingsToggle = document.getElementById('settings-toggle');
newSettingsToggle.addEventListener('click', (e) => {
e.preventDefault();
if (settingsSubmenu) settingsSubmenu.classList.toggle('hidden');
if (settingsChevron) settingsChevron.classList.toggle('rotate-180');
});
// Persistance : Garder ouvert si on est dans une sous-route admin
if (window.location.pathname.includes('administrateur')) {
if (settingsSubmenu) settingsSubmenu.classList.remove('hidden');
if (settingsChevron) settingsChevron.classList.add('rotate-180');
}
}
// --- GESTION DES MESSAGES FLASH (Auto-suppression 10s) ---
const flashes = document.querySelectorAll('.flash-message');
flashes.forEach((flash) => {
// Supprime le message après 10 secondes
setTimeout(() => {
// Animation de sortie
flash.classList.add('opacity-0', 'translate-x-10');
// Retrait du DOM après l'animation
setTimeout(() => flash.remove(), 500);
}, 10000);
});
}
// --- CORRECTIF DATA-TURBO-CONFIRM ---
// Force l'affichage de la confirmation native sur les liens avec data-turbo-confirm
document.addEventListener("turbo:click", (event) => {
const message = event.target.getAttribute("data-turbo-confirm");
if (message && !confirm(message)) {
event.preventDefault();
}
});
// S'exécute au premier chargement ET à chaque navigation Turbo
document.addEventListener('turbo:load', initAdminLayout);
// Fermer la sidebar mobile avant que Turbo ne mette en cache la page
document.addEventListener('turbo:before-cache', () => {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (sidebar) sidebar.classList.add('-translate-x-full');
if (overlay) overlay.classList.add('hidden');
});

View File

@@ -1 +1,11 @@
@import "tailwindcss";
form {
label {
color: white !important;
}
}
.animate-fadeIn { animation: fadeIn 0.3s ease-in-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }

View File

@@ -42,7 +42,9 @@
"phpoffice/phpspreadsheet": ">=5.4",
"phpstan/phpdoc-parser": "^2.3.1",
"presta/sitemap-bundle": "^4.2",
"sentry/sentry-symfony": "^5.8.3",
"scheb/2fa-bundle": "^7.13",
"scheb/2fa-google-authenticator": "^7.13",
"sentry/sentry-symfony": "^5.8",
"setasign/fpdi": "^2.6.4",
"spatie/mjml-php": "^1.2.5",
"stancer/stancer": ">=2.0.1",

193
composer.lock generated
View File

@@ -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": "bd9ebbc9c455efb1ac19197eaf03b368",
"content-hash": "4bb28dc935f256091e8b6e96cd1fbfa3",
"packages": [
{
"name": "async-aws/core",
@@ -7862,6 +7862,127 @@
],
"time": "2025-12-02T15:19:04+00:00"
},
{
"name": "scheb/2fa-bundle",
"version": "v7.13.1",
"source": {
"type": "git",
"url": "https://github.com/scheb/2fa-bundle.git",
"reference": "edcc14456b508aab37ec792cfc36793d04226784"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/edcc14456b508aab37ec792cfc36793d04226784",
"reference": "edcc14456b508aab37ec792cfc36793d04226784",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"symfony/config": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/event-dispatcher": "^6.4 || ^7.0",
"symfony/framework-bundle": "^6.4 || ^7.0",
"symfony/http-foundation": "^6.4 || ^7.0",
"symfony/http-kernel": "^6.4 || ^7.0",
"symfony/property-access": "^6.4 || ^7.0",
"symfony/security-bundle": "^6.4 || ^7.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/twig-bundle": "^6.4 || ^7.0"
},
"conflict": {
"scheb/two-factor-bundle": "*"
},
"suggest": {
"scheb/2fa-backup-code": "Emergency codes when you have no access to other methods",
"scheb/2fa-email": "Send codes by email",
"scheb/2fa-google-authenticator": "Google Authenticator support",
"scheb/2fa-totp": "Temporary one-time password (TOTP) support (Google Authenticator compatible)",
"scheb/2fa-trusted-device": "Trusted devices support"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Scheb\\TwoFactorBundle\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Scheb",
"email": "me@christianscheb.de"
}
],
"description": "A generic interface to implement two-factor authentication in Symfony applications",
"homepage": "https://github.com/scheb/2fa",
"keywords": [
"2fa",
"Authentication",
"symfony",
"two-factor",
"two-step"
],
"support": {
"source": "https://github.com/scheb/2fa-bundle/tree/v7.13.1"
},
"time": "2025-12-18T15:29:07+00:00"
},
{
"name": "scheb/2fa-google-authenticator",
"version": "v7.13.1",
"source": {
"type": "git",
"url": "https://github.com/scheb/2fa-google-authenticator.git",
"reference": "7ad34bbde343a0770571464127ee072aacb70a58"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/scheb/2fa-google-authenticator/zipball/7ad34bbde343a0770571464127ee072aacb70a58",
"reference": "7ad34bbde343a0770571464127ee072aacb70a58",
"shasum": ""
},
"require": {
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"scheb/2fa-bundle": "self.version",
"spomky-labs/otphp": "^11.0"
},
"suggest": {
"symfony/validator": "Needed if you want to use the Google Authenticator TOTP validator constraint"
},
"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 using Google Authenticator",
"homepage": "https://github.com/scheb/2fa",
"keywords": [
"2fa",
"Authentication",
"google-authenticator",
"symfony",
"two-factor",
"two-step"
],
"support": {
"source": "https://github.com/scheb/2fa-google-authenticator/tree/v7.13.1"
},
"time": "2025-12-04T15:55:14+00:00"
},
{
"name": "sentry/sentry",
"version": "4.19.1",
@@ -8321,6 +8442,76 @@
],
"time": "2025-11-13T13:00:34+00:00"
},
{
"name": "spomky-labs/otphp",
"version": "11.4.1",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/otphp.git",
"reference": "126c99b6cbbc18992cf3fba3b87931ba4e312482"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/126c99b6cbbc18992cf3fba3b87931ba4e312482",
"reference": "126c99b6cbbc18992cf3fba3b87931ba4e312482",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^2.0 || ^3.0",
"php": ">=8.1",
"psr/clock": "^1.0",
"symfony/deprecation-contracts": "^3.2"
},
"require-dev": {
"symfony/error-handler": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"OTPHP\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/Spomky-Labs/otphp/contributors"
}
],
"description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator",
"homepage": "https://github.com/Spomky-Labs/otphp",
"keywords": [
"FreeOTP",
"RFC 4226",
"RFC 6238",
"google authenticator",
"hotp",
"otp",
"totp"
],
"support": {
"issues": "https://github.com/Spomky-Labs/otphp/issues",
"source": "https://github.com/Spomky-Labs/otphp/tree/11.4.1"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-01-05T13:20:36+00:00"
},
{
"name": "spomky-labs/pki-framework",
"version": "1.4.1",

View File

@@ -19,4 +19,5 @@ return [
Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,5 @@
# See the configuration reference at https://symfony.com/bundles/SchebTwoFactorBundle/6.x/configuration.html
scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken

View File

@@ -1,14 +1,9 @@
# config/packages/security.yaml
security:
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
# Appelle votre provider d'utilisateurs.
# Ici, nous configurons un provider d'entité pour notre classe Account,
# en spécifiant 'username' comme propriété d'identification.
app_account_provider:
entity:
class: App\Entity\Account
property: email # Utilise le champ 'username' de votre entité Account pour l'authentification
property: email
firewalls:
dev:
@@ -17,32 +12,40 @@ security:
main:
lazy: true
provider: app_account_provider # Utilise le provider que nous avons défini ci-dessus
provider: app_account_provider
user_checker: App\Security\UserChecker
# --- AJOUT DE LA CONFIGURATION 2FA ---
two_factor:
auth_form_path: 2fa_login # Route d'affichage du formulaire
check_path: 2fa_login_check # Route de soumission du code
# -------------------------------------
form_login:
login_path: app_home # La route vers votre formulaire de connexion (GET)
check_path: app_home # L'URL où le formulaire POST sera soumis
enable_csrf: true # Active la protection CSRF
csrf_token_id: authenticate # ID du jeton CSRF (doit correspondre à celui dans votre Twig)
login_path: app_home
check_path: app_home
enable_csrf: true
csrf_token_id: authenticate
entry_point: App\Security\AuthenticationEntryPoint
custom_authenticator:
- App\Security\LoginFormAuthenticator
- App\Security\KeycloakAuthenticator
logout:
target: app_logout
# Configuration des algorithmes de hachage des mots de passe.
# Symfony choisira automatiquement le meilleur algorithme par défaut si non spécifié,
# mais vous pouvez le configurer explicitement.
logout:
path: app_logout # Assurez-vous d'utiliser 'path'
target: app_home
password_hashers:
App\Entity\Account: 'auto' # 'auto' sélectionne le meilleur algorithme disponible (recommandé)
# Ou pour spécifier bcrypt explicitement :
# App\Entity\Account:
# algorithm: bcrypt
App\Entity\Account: 'auto'
role_hierarchy:
ROLE_ROOT: [ROLE_ADMIN] #
ROLE_ROOT: [ROLE_ADMIN]
access_control:
- { path: ^/admin, roles: [ROLE_ADMIN] }
- { path: ^/, roles: PUBLIC_ACCESS } # Toutes les autres pages nécessitent une authentification complète
# Permettre l'accès aux pages 2FA même si on n'est pas encore pleinement "ROLE_ADMIN"
- { path: ^/2fa, roles: PUBLIC_ACCESS }
- { path: ^/crm, roles: [ROLE_ADMIN] }
- { path: ^/, roles: PUBLIC_ACCESS }

View File

@@ -7,3 +7,10 @@ controllers:
presta_sitemap:
resource: "@PrestaSitemapBundle/config/routing.yml"
2fa_login:
path: /2fa
controller: scheb_two_factor.form_controller::form
2fa_login_check:
path: /2fa_check

View File

@@ -0,0 +1,7 @@
2fa_login:
path: /2fa
defaults:
_controller: "scheb_two_factor.form_controller::form"
2fa_login_check:
path: /2fa_check

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260115174840 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 google_authenticator_secret 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 google_authenticator_secret');
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Controller\Dashboard;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Form\AccountType;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Repository\AccountRepository;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
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\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class AccountController extends AbstractController
{
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET','POST'])]
public function administrateur(AccountRepository $accountRepository): Response
{
return $this->render('dashboard/administrateur.twig',[
'admins' => $accountRepository->findAdmin(),
]);
}
#[Route(path: '/crm/administrateur/add', name: 'app_crm_administrateur_add', options: ['sitemap' => false], methods: ['GET','POST'])]
public function administrateurAdd(Request $request): Response
{
$account = new Account();
$account->setIsFirstLogin(true);
$account->setIsActif(false);
$form = $this->createForm(AccountType::class, $account);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
}
return $this->render('dashboard/administrateur/add.twig', [
'form' => $form->createView(),
]);
}
#[Route(path: '/crm/administrateur/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET','POST'])]
public function administrateurView(?Account $account): Response
{
}
#[Route(path: '/crm/administrateur/delete/{id}', name: 'app_crm_administrateur_delete_view', options: ['sitemap' => false], methods: ['GET','POST'])]
public function administrateurDelete(?Account $account): Response
{
}
}

View File

@@ -28,13 +28,4 @@ class HomeController extends AbstractController
{
return $this->render('dashboard/home.twig');
}
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET','POST'])]
public function administrateur(AccountRepository $accountRepository): Response
{
return $this->render('dashboard/administrateur.twig',[
'admins' => $accountRepository->findAll(),
]);
}
}

View File

@@ -18,7 +18,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, \Serializable
class Account implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -66,6 +66,8 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(type: 'string', nullable: true)]
private ?string $googleAuthenticatorSecret = null;
public function __construct()
{
@@ -193,22 +195,24 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
}
public function serialize()
public function __serialize(): array
{
return serialize(array(
$this->id,
$this->email,
$this->username,
));
return [
'id' => $this->id,
'email' => $this->email,
'username' => $this->username,
'googleAuthenticatorSecret' => $this->googleAuthenticatorSecret,
'uuid' => $this->uuid,
];
}
public function unserialize(string $data)
public function __unserialize(array $data): void
{
list (
$this->id,
$this->email,
$this->username,
) = unserialize($data);
$this->id = $data['id'] ?? null;
$this->email = $data['email'] ?? null;
$this->username = $data['username'] ?? null;
$this->googleAuthenticatorSecret = $data['googleAuthenticatorSecret'] ?? null;
$this->uuid = $data['uuid'] ?? null;
}
/**
@@ -288,4 +292,25 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
return $this;
}
public function isGoogleAuthenticatorEnabled(): bool
{
// La 2FA est active si l'utilisateur a un secret généré
return null !== $this->googleAuthenticatorSecret;
}
public function getGoogleAuthenticatorUsername(): string
{
return $this->username;
}
public function getGoogleAuthenticatorSecret(): ?string
{
return $this->googleAuthenticatorSecret;
}
public function setGoogleAuthenticatorSecret(?string $googleAuthenticatorSecret): void
{
$this->googleAuthenticatorSecret = $googleAuthenticatorSecret;
}
}

40
src/Form/AccountType.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
namespace App\Form;
use App\Entity\Account;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AccountType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('username',TextType::class,[
'label' => 'Utilisateur',
'required' => true,
])
->add('email',EmailType::class,[
'label' => 'Email',
'required' => true,
])
->add('firstName',TextType::class,[
'label' => 'Nom',
'required' => true,
])
->add('name',TextType::class,[
'label' => 'Nom',
'required' => true,
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class',Account::class);
}
}

View File

@@ -33,7 +33,22 @@ class AccountRepository extends ServiceEntityRepository implements PasswordUpgra
$this->getEntityManager()->flush();
}
/**
* @return Account[]
*/
public function findAdmin(): array
{
$entityManager = $this->getEntityManager();
$sql = 'SELECT * FROM account WHERE roles::text LIKE :role ORDER BY name ASC';
$rsm = new \Doctrine\ORM\Query\ResultSetMappingBuilder($entityManager);
$rsm->addRootEntityFromClassMetadata(Account::class, 'a');
$query = $entityManager->createNativeQuery($sql, $rsm);
$query->setParameter('role', '%"ROLE_ADMIN"%');
return $query->getResult();
}
}

View File

@@ -51,6 +51,6 @@ class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
);
}
return new RedirectResponse($this->urlGenerator->generate('app_login'));
return new RedirectResponse($this->urlGenerator->generate('app_home'));
}
}

View File

@@ -130,6 +130,19 @@
"presta/sitemap-bundle": {
"version": "v4.2.0"
},
"scheb/2fa-bundle": {
"version": "7.13",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "1e6f68089146853a790b5da9946fc5974f6fcd49"
},
"files": [
"config/packages/scheb_2fa.yaml",
"config/routes/scheb_2fa.yaml"
]
},
"sentry/sentry-symfony": {
"version": "5.8",
"recipe": {

View File

@@ -1,5 +1,93 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Administrateur{% endblock %}
{% block body %}
{{ dump(admins) }}
{% block title %}Administrateurs{% endblock %}
{% block actions %}
{# Bouton Ajouter un administrateur #}
<a href="{{ path('app_crm_administrateur_add') }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 transition-all dark:bg-blue-600 dark:hover:bg-blue-700">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Ajouter un administrateur
</a>
{% endblock %}
{% block body %}
<div class="p-4 md:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen w-full">
<div class="w-full">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800 dark:text-white">Liste des Administrateurs</h1>
<span class="px-3 py-1 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-200">
{{ admins|length }} membres
</span>
</div>
<div class="bg-white dark:bg-gray-800 shadow-md sm:rounded-lg border border-gray-200 dark:border-gray-700 overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-700 dark:text-gray-300">
<tr>
<th scope="col" class="px-6 py-4 font-bold">Utilisateur</th>
<th scope="col" class="px-6 py-4 font-bold">Email</th>
<th scope="col" class="px-6 py-4 font-bold">Statut</th>
<th scope="col" class="px-6 py-4 font-bold text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for admin in admins %}
<tr class="bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-col">
<span class="font-semibold text-gray-900 dark:text-white">{{ admin.firstName }} {{ admin.name }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400 italic">@{{ admin.username }}</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap italic text-gray-400">
{{ admin.email }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if admin.actif %}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400">
<span class="w-2 h-2 mr-2 bg-green-500 rounded-full animate-pulse"></span>
Actif
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-400">
<span class="w-2 h-2 mr-2 bg-red-500 rounded-full"></span>
Suspendu
</span>
{% endif %}
</td>
<td class="px-6 py-4 text-right whitespace-nowrap space-x-2">
{# Bouton Voir #}
<a href="{{ path('app_crm_administrateur_view', {id: admin.id}) }}"
class="inline-flex items-center px-3 py-2 text-xs font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-all">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
Gérer
</a>
{# Bouton Supprimer #}
<a href="{{ path('app_crm_administrateur_delete_view', {id: admin.id}) }}"
data-turbo-method="post"
data-turbo-confirm="Êtes-vous sûr de vouloir supprimer cet administrateur ?"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-red-600 bg-red-100/10 border border-red-600/20 rounded-lg hover:bg-red-600 hover:text-white transition-all duration-200 dark:text-red-500 dark:bg-red-900/20 dark:border-red-800/50 dark:hover:bg-red-600 dark:hover:text-white">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Supprimer
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 italic bg-gray-50/50 dark:bg-gray-800/50">
Aucun administrateur trouvé.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,109 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Nouvel Administrateur{% endblock %}
{% block actions %}
{# Bouton de retour vers la liste #}
<a href="{{ path('app_crm_administrateur') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Retour à la liste
</a>
{% endblock %}
{% block body %}
<div class="p-4 md:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen w-full">
<div class="w-full">
<div class="bg-white dark:bg-gray-800 shadow-md rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{# Bandeau d'information sur la procédure d'activation par email #}
<div class="flex p-4 text-sm text-blue-800 border-b border-blue-200 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800/50" role="alert">
<svg class="flex-shrink-0 inline w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<div>
<span class="font-bold">Procédure d'activation :</span>
Un email sera envoyé à l'adresse saisie. Le lien permettra au futur administrateur de <strong>valider son compte</strong> et de <strong>définir son mot de passe</strong> de manière sécurisée.
</div>
</div>
<div class="p-8">
{{ form_start(form, {'attr': {'class': 'space-y-8'}}) }}
{# Grille de formulaire en 2 colonnes sur desktop #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
{# Bloc IDENTIFIANT - Correction couleur Label dark:text-gray-200 #}
<div class="space-y-2">
{{ form_label(form.username, 'Identifiant (Username)', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-700 dark:text-gray-200'}
}) }}
{{ form_widget(form.username, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400',
'placeholder': 'Ex: jdoe'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.username) }}</div>
</div>
{# Bloc EMAIL #}
<div class="space-y-2">
{{ form_label(form.email, 'Adresse mail professionnelle', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-700 dark:text-gray-200'}
}) }}
{{ form_widget(form.email, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400',
'placeholder': 'email@ludikevent.fr'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.email) }}</div>
</div>
{# Bloc PRENOM #}
<div class="space-y-2">
{{ form_label(form.firstName, 'Prénom', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-700 dark:text-gray-200'}
}) }}
{{ form_widget(form.firstName, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.firstName) }}</div>
</div>
{# Bloc NOM #}
<div class="space-y-2">
{{ form_label(form.name, 'Nom de famille', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-700 dark:text-gray-200'}
}) }}
{{ form_widget(form.name, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.name) }}</div>
</div>
</div>
{# Pied de formulaire avec bouton d'action #}
<div class="flex items-center justify-end pt-8 border-t border-gray-200 dark:border-gray-700 mt-10">
<button type="submit" class="flex items-center justify-center text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-bold rounded-lg text-sm px-8 py-3.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 transition-all shadow-lg hover:shadow-blue-500/30">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
Créer et envoyer l'invitation
</button>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -3,181 +3,110 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tableau de Bord Administratif</title>
<!-- Chargement du CDN de Tailwind CSS -->
<title>{% block title %}Administration{% endblock %} - LudikEvent</title>
{{ vite_asset('admin.js', {}) }}
<style>
/* Configuration de la police Inter (utilisée par défaut par Tailwind) */
body {
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Style pour les cartes (utilisé pour l'effet de survol) */
.dashboard-card {
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
}
.dashboard-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* Styles spécifiques pour le mode sombre pour une meilleure clarté */
.dark .bg-gray-50 { background-color: #111827; }
.dark .bg-white { background-color: #1f2937; }
.dark .text-gray-800 { color: #f3f4f6; }
.dark .text-gray-900 { color: #ffffff; }
.dark .text-gray-500 { color: #9ca3af; }
.dark .border-gray-200 { border-color: #374151; }
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.5); }
.dark .shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.05); }
</style>
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100 transition-colors duration-300">
<div class="flex h-screen">
<!-- 1. Barre Latérale (Sidebar) -->
<body class="bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100 antialiased">
<div class="flex h-screen overflow-hidden">
{# Overlay pour mobile #}
<div id="sidebar-overlay" class="fixed inset-0 z-20 bg-black/50 lg:hidden hidden"></div>
{# SIDEBAR #}
<aside id="sidebar" class="fixed inset-y-0 left-0 z-30 w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transform -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out">
<div class="flex items-center justify-center h-16 border-b border-gray-200 dark:border-gray-700">
<span class="text-2xl font-bold text-primary-500">Tableau de Bord</span>
<div class="flex items-center justify-between px-6 h-16 border-b border-gray-200 dark:border-gray-700">
<span class="text-xl font-bold bg-gradient-to-r from-blue-600 to-indigo-500 bg-clip-text text-transparent">LudikEvent CRM</span>
</div>
<!-- Liens de Navigation -->
<nav class="flex flex-col p-4 space-y-2">
<a href="{{ path('app_crm') }}" class="flex items-center space-x-3 p-3 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-150 ease-in-out">
<!-- Icône SVG pour Clients (Users/People) -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M7.5 14a6.5 6.5 0 100-13 6.5 6.5 0 000 13z"></path></svg>
<span>Tableau de bord</span>
<nav class="flex flex-col p-4 space-y-1 h-[calc(100vh-64px)] overflow-y-auto">
{% macro nav_link(path, label, icon_svg, current_route) %}
{% set isActive = app.request.get('_route') == current_route %}
<a href="{{ path }}" class="flex items-center space-x-3 p-3 rounded-lg transition-all duration-200 {{ isActive ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">{{ icon_svg|raw }}</svg>
<span class="font-medium text-sm">{{ label }}</span>
</a>
{% endmacro %}
<!-- Lien: Clients -->
<a href="#" class="flex items-center space-x-3 p-3 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-150 ease-in-out">
<!-- Icône SVG pour Clients (Users/People) -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M7.5 14a6.5 6.5 0 100-13 6.5 6.5 0 000 13z"></path></svg>
<span>Clients</span>
</a>
{% import _self as menu %}
<!-- Lien: Contrats de location -->
<a href="#" class="flex items-center space-x-3 p-3 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-150 ease-in-out">
<!-- Icône SVG pour Contrats (Documents/Paper) -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
<span>Contrats de location</span>
</a>
{{ menu.nav_link(path('app_crm'), 'Tableau de bord', '<path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>', 'app_crm') }}
{{ menu.nav_link('#', 'Clients', '<path d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>', 'app_clients') }}
{{ menu.nav_link('#', 'Articles', '<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>', 'app_articles') }}
<a href="#" class="flex items-center space-x-3 p-3 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-150 ease-in-out">
<!-- Icône SVG pour Clients (Users/People) -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M7.5 14a6.5 6.5 0 100-13 6.5 6.5 0 000 13z"></path></svg>
<span>Articles</span>
</a>
<a href="#" class="flex items-center space-x-3 p-3 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-150 ease-in-out">
<!-- Icône SVG pour Clients (Users/People) -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M7.5 14a6.5 6.5 0 100-13 6.5 6.5 0 000 13z"></path></svg>
<span>Contrat</span>
</a>
<!-- Menu Paramètres (avec sous-menus) -->
<div>
<button id="settings-toggle" class="w-full flex items-center justify-between p-3 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-150 ease-in-out focus:outline-none">
{# Groupe Paramètres #}
<div class="pt-2">
{% set isAdminRoute = app.request.get('_route') matches '/^app_crm_administrateur/' %}
<button id="settings-toggle" class="w-full flex items-center justify-between p-3 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-150">
<div class="flex items-center space-x-3">
<!-- Icône SVG pour Paramètres -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37a1.724 1.724 0 002.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<span>Paramètres</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37a1.724 1.724 0 002.572-1.065z"></path></svg>
<span class="font-medium text-sm">Paramètres</span>
</div>
<!-- Icône de Chevron (pour l'état ouvert/fermé) -->
<svg id="settings-chevron" class="w-4 h-4 transform transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
<svg id="settings-chevron" class="w-4 h-4 transition-transform duration-200 {{ isAdminRoute ? 'rotate-180' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 9l-7 7-7-7"></path></svg>
</button>
<!-- Sous-menu -->
<ul id="settings-submenu" class="ml-4 mt-1 space-y-1 hidden">
<li class="pl-2">
<a href="{{ path('app_crm_administrateur') }}" class="block p-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-150 ease-in-out">
Administrateur
<ul id="settings-submenu" class="ml-9 mt-1 space-y-1 {{ isAdminRoute ? '' : 'hidden' }}">
<li>
<a href="{{ path('app_crm_administrateur') }}" class="block p-2 rounded-lg text-sm {{ isAdminRoute ? 'text-blue-600 font-bold dark:text-blue-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' }}">
Administrateurs
</a>
</li>
<li class="pl-2">
<a href="#" class="block p-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-150 ease-in-out">
Services
</a>
<li>
<a href="#" class="block p-2 rounded-lg text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">Services</a>
</li>
</ul>
</div>
</nav>
</aside>
<!-- 2. Contenu Principal -->
<main class="flex-1 lg:ml-64 overflow-y-auto">
<!-- 2.1 En-tête (Header) -->
<header class="h-16 flex items-center justify-between px-6 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-20 shadow-sm dark:shadow-none">
{# CONTENU PRINCIPAL #}
<main class="flex-1 flex flex-col min-w-0 lg:ml-64 bg-gray-50 dark:bg-gray-900">
<!-- Bouton pour ouvrir la barre latérale sur mobile -->
<button id="sidebar-toggle" class="lg:hidden text-gray-500 dark:text-gray-400 hover:text-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 p-2 rounded-md">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
{# Header #}
<header class="h-16 flex items-center justify-between px-6 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-20">
<button id="sidebar-toggle" class="lg:hidden text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded-md transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"></path></svg>
</button>
<div class="flex items-center space-x-4 ml-auto">
<div class="text-right hidden sm:block">
<p class="text-xs font-medium text-gray-900 dark:text-white">{{ app.user.username|default('Admin') }}</p>
<a href="{{ path('app_logout') }}" class="text-[10px] text-red-500 hover:underline">Déconnexion</a>
</div>
</div>
</header>
{# Zone Flash Messages #}
<div id="flash-container" class="fixed top-20 right-6 z-50 flex flex-col gap-3 w-80">
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="flash-message transform transition-all duration-500 p-4 rounded-lg shadow-lg border flex items-center justify-between {{ label == 'success' ? 'bg-green-100 border-green-200 text-green-800 dark:bg-green-900/80 dark:text-green-300 dark:border-green-800' : 'bg-red-100 border-red-200 text-red-800 dark:bg-red-900/80 dark:text-red-300 dark:border-red-800' }}">
<p class="text-sm font-medium">{{ message }}</p>
<button type="button" onclick="this.parentElement.remove()" class="ml-4 opacity-50 hover:opacity-100">×</button>
</div>
{% endfor %}
{% endfor %}
</div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-semibold text-gray-900 dark:text-gray-100">
{% block title %}{% endblock %}
{# Zone Contenu #}
<div class="p-6 md:p-8">
<div class="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{% block title_header %}{{ block('title') }}{% endblock %}
</h1>
<div class="flex space-x-2">
<div class="flex items-center space-x-3">
{% block actions %}{% endblock %}
</div>
</div>
{% block body %}{% endblock %}
<div class="animate-fadeIn w-full">
{% block body %}{% endblock %}
</div>
</div>
</main>
</div>
<!-- Script JavaScript pour la fonctionnalité de la barre latérale mobile et le menu déroulant des paramètres -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('sidebar');
const toggleButton = document.getElementById('sidebar-toggle');
// Fonction pour basculer la visibilité de la barre latérale
toggleButton.addEventListener('click', () => {
sidebar.classList.toggle('-translate-x-full');
});
// Masquer la barre latérale si on clique en dehors (sur mobile)
document.querySelector('main').addEventListener('click', () => {
if (!sidebar.classList.contains('-translate-x-full') && window.innerWidth < 1024) {
sidebar.classList.add('-translate-x-full');
}
});
// Assurer que la barre latérale est visible sur les grands écrans au chargement
const handleResize = () => {
if (window.innerWidth >= 1024) {
sidebar.classList.remove('-translate-x-full');
} else {
// Cacher si on passe en mobile, sauf si déjà ouvert
if (sidebar.classList.contains('lg:translate-x-0')) {
sidebar.classList.add('-translate-x-full');
}
}
};
window.addEventListener('resize', handleResize);
handleResize(); // Appel initial
// --- Logique du Menu Paramètres ---
const settingsToggle = document.getElementById('settings-toggle');
const settingsSubmenu = document.getElementById('settings-submenu');
const settingsChevron = document.getElementById('settings-chevron');
if (settingsToggle) {
settingsToggle.addEventListener('click', (e) => {
e.preventDefault(); // Empêche la navigation et permet le dépliage
settingsSubmenu.classList.toggle('hidden');
settingsChevron.classList.toggle('rotate-180');
});
}
});
</script>
</body>
</html>