feat(ansible/caddy): Supprime CSP statique et Permissions-Policy obsolète
🐛 fix(assets/admin): Corrige la gestion du menu admin et des flashs
 feat(Twig/ViteAssetExtension): Ajoute CSP nonce et gère les favicons
🐛 fix(Entity/AuditLog): Corrige la relation ManyToOne avec Account
 feat: Ajoute NelmioSecurityBundle pour gérer la sécurité CSP
```
This commit is contained in:
Serreau Jovann
2026-01-15 20:35:46 +01:00
parent 2aa0ce5c1e
commit 75c419ba06
12 changed files with 387 additions and 115 deletions

View File

@@ -13,10 +13,6 @@ intranet.ludikevent.fr, signature.ludikevent.fr {
# --- BLOC HEADER AVEC CSP ---
header {
X-Robots-Tag "noindex, nofollow, nosnippet, noarchive"
# CSP sur une seule ligne pour éviter tout problème d'interprétation par Caddy
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://sentry.esy-web.dev https://chat.esy-web.dev https://auth.esy-web.dev https://static.cloudflareinsights.com; connect-src 'self' https://sentry.esy-web.dev https://chat.esy-web.dev https://auth.esy-web.dev https://cloudflareinsights.com; frame-src 'self' https://chat.esy-web.dev https://auth.esy-web.dev; style-src 'self' 'unsafe-inline' https://chat.esy-web.dev; img-src 'self' data: https://chat.esy-web.dev; font-src 'self' data:; frame-ancestors 'none';"
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=()"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"

View File

@@ -1,76 +1,121 @@
import './admin.scss'
import * as Turbo from "@hotwired/turbo"
// Cette fonction initialise tous les écouteurs d'événements
/**
* Initialise les composants de l'interface d'administration.
* Cette fonction est appelée à chaque chargement de page par Turbo.
*/
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 ---
// --- 1. GESTION DE LA 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', () => {
toggleBtn.onclick = () => {
sidebar.classList.toggle('-translate-x-full');
overlay.classList.toggle('hidden');
});
};
overlay.addEventListener('click', () => {
overlay.onclick = () => {
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');
// --- 2. GESTION DU DROPDOWN (PARAMÈTRES) ---
if (settingsToggle && settingsSubmenu) {
const settingsChevron = settingsToggle.querySelector('svg:last-child');
newSettingsToggle.addEventListener('click', (e) => {
/**
* Alterne l'état du dropdown avec une animation de glissement.
* @param {boolean} show - Forcer l'ouverture ou la fermeture
* @param {boolean} animate - Activer ou non la transition CSS
*/
const toggleDropdown = (show, animate = true) => {
if (!animate) settingsSubmenu.style.transition = 'none';
if (show) {
settingsSubmenu.classList.remove('hidden');
// scrollHeight permet de calculer la hauteur réelle du contenu
settingsSubmenu.style.maxHeight = settingsSubmenu.scrollHeight + "px";
settingsChevron?.classList.add('rotate-180');
localStorage.setItem('admin_settings_open', 'true');
} else {
settingsSubmenu.style.maxHeight = "0px";
settingsChevron?.classList.remove('rotate-180');
localStorage.setItem('admin_settings_open', 'false');
// On cache l'élément après l'animation pour l'accessibilité
if (animate) {
setTimeout(() => {
if (settingsSubmenu.style.maxHeight === "0px") {
settingsSubmenu.classList.add('hidden');
}
}, 300);
} else {
settingsSubmenu.classList.add('hidden');
}
}
if (!animate) {
// Forcer un recalcul pour réactiver la transition proprement
settingsSubmenu.offsetHeight;
settingsSubmenu.style.transition = '';
}
};
// Événement de clic
settingsToggle.onclick = (e) => {
e.preventDefault();
if (settingsSubmenu) settingsSubmenu.classList.toggle('hidden');
if (settingsChevron) settingsChevron.classList.toggle('rotate-180');
});
const isClosed = settingsSubmenu.style.maxHeight === "0px" || settingsSubmenu.classList.contains('hidden');
toggleDropdown(isClosed);
};
// 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');
// --- PERSISTANCE ---
// On vérifie si on est sur une page appartenant au menu ou si l'utilisateur l'avait laissé ouvert
const isSettingsRoute = window.location.pathname.includes('/crm/administrateur') ||
window.location.pathname.includes('/crm/logs');
const wasOpen = localStorage.getItem('admin_settings_open') === 'true';
if (isSettingsRoute || wasOpen) {
toggleDropdown(true, false); // Ouverture immédiate sans animation
}
// --- HIGHLIGHT DU LIEN ACTIF ---
settingsSubmenu.querySelectorAll('a').forEach(link => {
if (window.location.pathname === link.getAttribute('href')) {
link.classList.add('text-blue-600', 'dark:text-blue-400', 'font-semibold');
link.classList.remove('text-slate-500');
}
});
}
// --- GESTION DES MESSAGES FLASH (Auto-suppression 10s) ---
const flashes = document.querySelectorAll('.flash-message');
flashes.forEach((flash) => {
// Supprime le message après 10 secondes
// --- 3. GESTION DES MESSAGES FLASH (Auto-suppression) ---
document.querySelectorAll('.flash-message').forEach((flash) => {
setTimeout(() => {
// Animation de sortie
flash.classList.add('opacity-0', 'translate-x-10');
// Retrait du DOM après l'animation
setTimeout(() => flash.remove(), 500);
}, 10000);
}, 8000);
});
}
// --- CORRECTIF DATA-TURBO-CONFIRM ---
// Force l'affichage de la confirmation native sur les liens avec data-turbo-confirm
// Turbo 7+ intercepte les clics, on réimplémente une confirmation native simple
document.addEventListener("turbo:click", (event) => {
const message = event.target.getAttribute("data-turbo-confirm");
const message = event.target.closest("[data-turbo-confirm]")?.getAttribute("data-turbo-confirm");
if (message && !confirm(message)) {
event.preventDefault();
}
});
// S'exécute au premier chargement ET à chaque navigation Turbo
// Exécution au chargement initial et à chaque navigation Turbo
document.addEventListener('turbo:load', initAdminLayout);
// Fermer la sidebar mobile avant que Turbo ne mette en cache la page
// Nettoyage avant la mise en cache de Turbo (évite les bugs visuels au retour arrière)
document.addEventListener('turbo:before-cache', () => {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');

View File

@@ -36,6 +36,7 @@
"mittwald/vault-php": "^3.0.2",
"mobiledetect/mobiledetectlib": "^4.8.10",
"nelmio/cors-bundle": "^2.6.1",
"nelmio/security-bundle": "^3.8",
"ovh/ovh": ">=3.5",
"pear/net_dns2": ">=2.0.7",
"phpdocumentor/reflection-docblock": "^5.6.6",

211
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": "45482c705146a5e69d39c6e43bf018b1",
"content-hash": "4ce617f198e010903ec5351925259b10",
"packages": [
{
"name": "async-aws/core",
@@ -699,6 +699,78 @@
},
"time": "2025-11-27T18:57:36+00:00"
},
{
"name": "composer/ca-bundle",
"version": "1.5.10",
"source": {
"type": "git",
"url": "https://github.com/composer/ca-bundle.git",
"reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63",
"reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-pcre": "*",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8 || ^9",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\CaBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.",
"keywords": [
"cabundle",
"cacert",
"certificate",
"ssl",
"tls"
],
"support": {
"irc": "irc://irc.freenode.org/composer",
"issues": "https://github.com/composer/ca-bundle/issues",
"source": "https://github.com/composer/ca-bundle/tree/1.5.10"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-12-08T15:06:51+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
@@ -6163,6 +6235,80 @@
},
"time": "2026-01-12T15:59:08+00:00"
},
{
"name": "nelmio/security-bundle",
"version": "v3.8.0",
"source": {
"type": "git",
"url": "https://github.com/nelmio/NelmioSecurityBundle.git",
"reference": "2fafee1cdda1d5952554c44eef4c3c8566d56f40"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nelmio/NelmioSecurityBundle/zipball/2fafee1cdda1d5952554c44eef4c3c8566d56f40",
"reference": "2fafee1cdda1d5952554c44eef4c3c8566d56f40",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"symfony/deprecation-contracts": "^2.5 || ^3",
"symfony/framework-bundle": "^5.4 || ^6.3 || ^7.0 || ^8.0",
"symfony/http-kernel": "^5.4 || ^6.3 || ^7.0 || ^8.0",
"symfony/security-core": "^5.4 || ^6.3 || ^7.0 || ^8.0",
"symfony/security-csrf": "^5.4 || ^6.3 || ^7.0 || ^8.0",
"symfony/security-http": "^5.4 || ^6.3 || ^7.0 || ^8.0",
"symfony/yaml": "^5.4 || ^6.3 || ^7.0 || ^8.0",
"ua-parser/uap-php": "^3.4.4"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^9.5 || ^10.1 || ^11.0",
"psr/cache": "^1.0 || ^2.0 || ^3.0",
"symfony/browser-kit": "^5.4 || ^6.3 || ^7.0 || ^8.0",
"symfony/cache": "^5.4 || ^6.3 || ^7.0 || ^8.0",
"symfony/phpunit-bridge": "^6.3 || ^7.0 || ^8.0",
"symfony/twig-bundle": "^5.4 || ^6.3 || ^7.0 || ^8.0",
"twig/twig": "^2.10 || ^3.0"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Nelmio\\SecurityBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nelmio",
"homepage": "http://nelm.io"
},
{
"name": "Symfony Community",
"homepage": "https://github.com/nelmio/NelmioSecurityBundle/contributors"
}
],
"description": "Extra security-related features for Symfony: signed/encrypted cookies, HTTPS/SSL/HSTS handling, cookie session storage, ...",
"keywords": [
"security"
],
"support": {
"issues": "https://github.com/nelmio/NelmioSecurityBundle/issues",
"source": "https://github.com/nelmio/NelmioSecurityBundle/tree/v3.8.0"
},
"time": "2026-01-14T19:38:55+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.11.0",
@@ -14995,6 +15141,69 @@
],
"time": "2025-12-14T11:28:47+00:00"
},
{
"name": "ua-parser/uap-php",
"version": "v3.10.0",
"source": {
"type": "git",
"url": "https://github.com/ua-parser/uap-php.git",
"reference": "f44bdd1b38198801cf60b0681d2d842980e47af5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ua-parser/uap-php/zipball/f44bdd1b38198801cf60b0681d2d842980e47af5",
"reference": "f44bdd1b38198801cf60b0681d2d842980e47af5",
"shasum": ""
},
"require": {
"composer/ca-bundle": "^1.1",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^0.12.33",
"phpunit/phpunit": "^8 || ^9",
"symfony/console": "^3.4 || ^4.2 || ^4.3 || ^5.0",
"symfony/filesystem": "^3.4 || ^4.2 || ^4.3 || ^5.0",
"symfony/finder": "^3.4 || ^4.2 || ^4.3 || ^5.0",
"symfony/yaml": "^3.4 || ^4.2 || ^4.3 || ^5.0",
"vimeo/psalm": "^3.12"
},
"suggest": {
"symfony/console": "Required for CLI usage - ^3.4 || ^4.3 || ^5.0",
"symfony/filesystem": "Required for CLI usage - ^3.4 || ^4.3 || ^5.0",
"symfony/finder": "Required for CLI usage - ^3.4 || ^4.3 || ^5.0",
"symfony/yaml": "Required for CLI usage - ^3.4 || ^4.3 || ^5.0"
},
"bin": [
"bin/uaparser"
],
"type": "library",
"autoload": {
"psr-4": {
"UAParser\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dave Olsen",
"email": "dmolsen@gmail.com"
},
{
"name": "Lars Strojny",
"email": "lars@strojny.net"
}
],
"description": "A multi-language port of Browserscope's user agent parser.",
"support": {
"issues": "https://github.com/ua-parser/uap-php/issues",
"source": "https://github.com/ua-parser/uap-php/tree/v3.10.0"
},
"time": "2025-07-17T15:43:24+00:00"
},
{
"name": "vich/uploader-bundle",
"version": "v2.9.1",

View File

@@ -20,4 +20,5 @@ return [
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,12 @@
# config/packages/dev/nelmio_security.yaml
nelmio_security:
csp:
enforce:
connect-src:
- "'self'"
- "ws://localhost:5173" # Autorise le WebSocket de Vite
- "http://localhost:5173" # Autorise les assets de Vite
- "https://sentry.esy-web.dev"
- "https://chat.esy-web.dev"
- "https://auth.esy-web.dev"
- "https://cloudflareinsights.com"

View File

@@ -0,0 +1,37 @@
nelmio_security:
# Content Security Policy (CSP)
csp:
enforce:
default-src: ["'self'"]
script-src:
- "'self'"
- "nonce"
- "https://sentry.esy-web.dev"
- "https://chat.esy-web.dev"
- "https://auth.esy-web.dev"
- "https://static.cloudflareinsights.com"
- "'strict-dynamic'"
connect-src:
- "'self'"
- "https://sentry.esy-web.dev"
- "https://chat.esy-web.dev"
- "https://auth.esy-web.dev"
- "https://cloudflareinsights.com"
frame-src:
- "'self'"
- "https://chat.esy-web.dev"
- "https://auth.esy-web.dev"
style-src:
- "'self'"
- "'unsafe-inline'"
- "https://chat.esy-web.dev"
img-src:
- "'self'"
- "data:"
- "https://chat.esy-web.dev"
font-src:
- "'self'"
- "data:"
frame-ancestors: ["'none'"]
# Optionnel : forcer le passage en HTTPS
upgrade-insecure-requests: false

View File

@@ -16,7 +16,10 @@ services:
App\:
resource: '../src/'
App\Twig\ViteAssetExtension:
arguments:
$manifest: '%kernel.project_dir%/public/build/.vite/manifest.json'
$cache: '@vite_cache_pool'
# Utilisation du listener de Nelmio (identifiant officiel)
$cspListener: '@nelmio_security.csp_listener'

View File

@@ -14,7 +14,7 @@ class AuditLog
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
#[ORM\ManyToOne(targetEntity: Account::class, inversedBy: 'auditLogs')]
#[ORM\JoinColumn(nullable: false)]
private ?Account $account = null;

View File

@@ -6,24 +6,20 @@ use Detection\MobileDetect;
use Psr\Cache\CacheItemPoolInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Nelmio\SecurityBundle\EventListener\ContentSecurityPolicyListener;
class ViteAssetExtension extends AbstractExtension
{
// Clé réservée dans le manifest Vite pour le HTML généré des favicons.
const FAVICON_MANIFEST_KEY = '_FAVICONS_HTML_';
private ?array $manifestData = null;
const CACHE_KEY = 'vite_manifest';
private ?array $manifestData = null;
private readonly bool $isDev;
public function __construct(
private readonly string $manifest,
private readonly CacheItemPoolInterface $cache,
private readonly ContentSecurityPolicyListener $cspListener,
) {
// Respecte la logique existante : VITE_LOAD == "0" est considéré comme DEV.
$this->isDev = $_ENV['VITE_LOAD'] == "0";
$this->isDev = $_ENV['VITE_LOAD'] === "0";
}
public function getFunctions(): array
@@ -31,20 +27,25 @@ class ViteAssetExtension extends AbstractExtension
return [
new TwigFunction('vite_asset', $this->asset(...), ['is_safe' => ['html']]),
new TwigFunction('isMobile', $this->isMobile(...), ['is_safe' => ['html']]),
// Nouvelle fonction Twig pour inclure les liens de favicons
new TwigFunction('vite_favicons', $this->favicons(...), ['is_safe' => ['html']])
];
}
public function isMobile()
/**
* Récupère le nonce pour les scripts via le Listener de Nelmio
*/
private function getNonce(): string
{
// Dans la v3.8, on utilise getNonce('script') sur le listener
return $this->cspListener->getNonce('script');
}
public function isMobile(): bool
{
$detect = new MobileDetect();
return $detect->isMobile() || $detect->isTablet();
}
/**
* Charge le manifeste s'il n'est pas déjà chargé et met en cache.
*/
private function loadManifest(): void
{
if ($this->manifestData === null) {
@@ -53,106 +54,61 @@ class ViteAssetExtension extends AbstractExtension
$this->manifestData = $item->get();
} else {
if (!file_exists($this->manifest)) {
// En cas d'erreur de fichier, initialise à un tableau vide
$this->manifestData = [];
return;
}
$this->manifestData = json_decode((string)file_get_contents($this->manifest), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->manifestData = [];
}
$item->set($this->manifestData);
$this->cache->save($item);
}
}
}
// --- Gestion des assets JS/CSS (non modifiée) ---
public function asset(string $entry, array $deps): string
public function asset(string $entry, array $deps = []): string
{
if ($this->isDev) {
return $this->assetDev($entry, $deps);
}
return $this->assetProd($entry);
return $this->isDev ? $this->assetDev($entry, $deps) : $this->assetProd($entry);
}
public function assetDev(string $entry, array $deps): string
{
$html = <<<HTML
<script type="module" src="http://localhost:5173/assets/@vite/client"></script>
HTML;
return $html . <<<HTML
<script type="module" src="http://localhost:5173/assets/{$entry}" defer></script>
HTML;
$nonce = $this->getNonce();
return <<<HTML
<script type="module" src="http://localhost:5173/assets/@vite/client" nonce="{$nonce}"></script>
<script type="module" src="http://localhost:5173/assets/{$entry}" nonce="{$nonce}" defer></script>
HTML;
}
public function assetProd(string $entry): string
{
$this->loadManifest();
$nonce = $this->getNonce();
$file = $this->manifestData[$entry]['file'] ?? '';
$css = $this->manifestData[$entry]['css'] ?? [];
$imports = $this->manifestData[$entry]['imports'] ?? [];
$html = <<<HTML
<script type="module" src="/build/{$file}" crossorigin="anonymous" defer></script>
HTML;
<script type="module" src="/build/{$file}" crossorigin="anonymous" nonce="{$nonce}" defer></script>
HTML;
foreach ($css as $cssFile) {
$html .= <<<HTML
<link rel="stylesheet" rel="preload" media="screen" href="/build/{$cssFile}" crossorigin="anonymous"/>
HTML;
}
foreach ($imports as $import) {
$import = str_replace("_vendor","vendor",$import);
$import = str_replace("_turbo","turbo",$import);
$html .= <<<HTML
<link rel="modulepreload" href="/build/{$import}" crossorigin="anonymous"/>
HTML;
$html .= '<link rel="stylesheet" href="/build/'.$cssFile.'" crossorigin="anonymous"/>';
}
return $html;
}
// --- Nouvelle Gestion des Favicons ---
public function favicons(): string
{
if ($this->isDev) {
return $this->faviconsDev();
}
return $this->faviconsProd();
return $this->isDev ? '<link rel="icon" href="/favicon.ico">' : $this->faviconsProd();
}
public function faviconsDev(): string
{
// En mode dev, on assume qu'un fichier favicon.ico ou favicon.png
// standard est présent dans le répertoire public.
return <<<HTML
<link rel="icon" type="image/x-icon" href="/favicon.ico">
HTML;
}
public function faviconsProd(): string
private function faviconsProd(): string
{
$this->loadManifest();
// Récupère le bloc HTML complet généré par le plugin dans le manifest.
// On suppose que l'entrée est un tableau associatif avec la clé 'html'.
$faviconData = $this->manifestData;
$faviconHtml = "";
foreach ($faviconData as $key =>$favicon) {
if(!str_contains($key,".js")) {
$faviconHtml .= <<<HTML
<link rel="icon" href="/build/{$favicon['file']}" type="image/x-icon">
HTML;
foreach ($this->manifestData as $key => $favicon) {
if(!str_contains($key, ".js") && isset($favicon['file'])) {
$faviconHtml .= '<link rel="icon" href="/build/'.$favicon['file'].'" type="image/x-icon">';
}
}
return $faviconHtml;

View File

@@ -100,6 +100,18 @@
"config/packages/nelmio_cors.yaml"
]
},
"nelmio/security-bundle": {
"version": "3.8",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "71045833e4f882ad9de8c95fe47efb99a1eec2f7"
},
"files": [
"config/packages/nelmio_security.yaml"
]
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {

View File

@@ -69,7 +69,7 @@
<svg class="w-4 h-4 transition-transform duration-300 {{ isOpen ? 'rotate-180' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</button>
<div id="settings-submenu" class="mt-2 space-y-1 overflow-hidden transition-all duration-300 {{ isOpen ? 'max-h-40' : 'max-h-0' }}">
<div id="settings-submenu" class="mt-2 space-y-1 overflow-hidden transition-all duration-300 max-h-0 hidden">
<a href="{{ path('app_crm_administrateur') }}" class="block px-12 py-2 text-sm {{ isAdminActive ? 'text-blue-600 font-bold' : 'text-slate-500 hover:text-slate-900 dark:hover:text-white' }}">Gestion Admins</a>
<a href="{{ path('app_crm_audit_logs') }}" class="block px-12 py-2 text-sm {{ isLogsActive ? 'text-blue-600 font-bold' : 'text-slate-500 hover:text-slate-900 dark:hover:text-white' }}">Traçabilité (Logs)</a>
</div>