[+] chore(root): Initialise le projet avec une structure de base

Crée la structure de base du projet Symfony, incluant les entités,
services, formulaires, et templates nécessaires pour la gestion des
comptes utilisateurs, la sécurité, et la gestion des mots de passe
oubliés. Ajoute également la configuration pour la gestion des assets
avec Vite, la gestion des fichiers avec Flysystem, et la
génération de sitemaps.
```
This commit is contained in:
Serreau Jovann
2025-12-11 17:22:26 +01:00
parent f9987d525e
commit 662bb0bcc6
89 changed files with 18001 additions and 6950 deletions

41
.env
View File

@@ -15,17 +15,10 @@
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_ENV=prod
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
@@ -33,7 +26,7 @@ DEFAULT_URI=http://localhost
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
DATABASE_URL="postgresql://symfony_user:ChangeMeInProd!@db:5432/app_db?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
@@ -44,5 +37,33 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
###> symfony/mailer ###
MAILER_DSN=null://null
MAILER_DSN='smtp://mailhog:1025'
###< symfony/mailer ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
VITE_LOAD=0
REDIS_DSN="redis://redis:6379"
REAL_MAIL=0
PATH_URL=https://esyweb.local
DEV_URL=https://240fba7426df.ngrok-free.app
VAPID_PK=DsOg7jToRSD-VpNSV1Gt3YAhSwz4l-nqeu7yFvzbSxg
VAPID_PC=BKz0kdcsG6kk9KxciPpkfP8kEDAd408inZecij5kBDbQ1ZGZSNwS4KZ8FerC28LFXvgSqpDXtor3ePo0zBCdNqo
CLOUDFLARE_ZONE_ID=
CLOUDFLARE_API_TOKEN=-h
###> google/apiclient ###
GOOGLE_API_KEY=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_AUTH_CONFIG=%kernel.project_dir%/google.json
GOOGLE_APPLICATION_CREDENTIALS=%kernel.project_dir%/google.json
###< google/apiclient ###
###> sentry/sentry-symfony ###
SENTRY_DSN=""
###< sentry/sentry-symfony ###
DEFAULT_URI=https://esyweb.local

23
.gitignore vendored
View File

@@ -1,12 +1,13 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/.env.test
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
/public/storage/
/public/tmp/*.pdf
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
@@ -15,6 +16,22 @@
###< phpunit/phpunit ###
###> symfony/asset-mapper ###
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###
.idea
node_modules
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
###> liip/imagine-bundle ###
/public/media/cache/
###< liip/imagine-bundle ###
.coverage
coverage/
.phpunit.cache
/public/build
bun.lock
bun.lockd

1
CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* jovann@siteconseil.fr

12
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,12 @@
# 🤐 Politique de Contribution
Ce projet est strictement confidentiel et **n'accepte aucune contribution externe**.
Aucune demande d'accès, pull request ou proposition de modification ne sera acceptée.
L'accès au code source est limité aux membres autorisés explicitement par SARL SITECONSEIL.
Toute tentative d'accès non autorisé ou de diffusion du contenu de ce projet pourra faire l'objet de poursuites légales.
---
Pour toute question, veuillez contacter : [s.com@siteconseil.fr]

2
assets/admin.js Normal file
View File

@@ -0,0 +1,2 @@
import './admin.scss'
import * as Turbo from "@hotwired/turbo"

1
assets/admin.scss Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -1,10 +1,594 @@
import './stimulus_bootstrap.js';
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import './styles/app.css';
import './app.scss'
import * as Turbo from "@hotwired/turbo"
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
// --- CLÉS DE STOCKAGE ET VAPID ---
const VAPID_PUBLIC_KEY = "BKz0kdcsG6kk9KxciPpkfP8kEDAd408inZecij5kBDbQ1ZGZSNwS4KZ8FerC28LFXvgSqpDXtor3ePo0zBCdNqo";
const COOKIE_STORAGE_KEY = 'cookies_accepted'; // Clé pour le consentement des cookies
// --- MESSAGES ET TRADUCTIONS (fr et en) ---
const MESSAGES = {
fr: {
// Notifications
notificationTitle: "🔔 Activer les notifications",
notificationText: "Recevez les nouvelles, les promotions et les événements de l'association.",
notificationButton: "Activer",
notificationClose: "Fermer la notification",
// Cookies
cookieText: 'Ce site utilise uniquement des cookies de <strong>fonctionnement</strong> et de <strong>sécurité</strong> (e.g. Cloudflare) essentiels pour son bon fonctionnement. Aucune donnée personnelle n\'est collectée.',
cookieLink: "Politique de cookies",
cookieButton: "Accepter",
},
en: {
// Notifications
notificationTitle: "🔔 Enable Notifications",
notificationText: "Receive news, promotions, and association events.",
notificationButton: "Enable",
notificationClose: "Close notification",
// Cookies
cookieText: 'This website only uses <strong>functional</strong> and <strong>security</strong> cookies (e.g. Cloudflare) essential for its proper operation. No personal data is collected.',
cookieLink: "Cookie Policy",
cookieButton: "Accept",
}
};
/**
* Détermine la langue à utiliser, en privilégiant l'attribut 'lang' du document HTML,
* puis la langue du navigateur. Retourne l'ensemble de messages correspondant.
* @returns {object} L'objet de messages pour la langue sélectionnée.
*/
function getLanguageMessages() {
let langCode = 'fr'; // Français par défaut
// 1. Tente de lire la langue depuis l'attribut 'lang' du tag <html>
const htmlLang = document.documentElement.lang;
if (htmlLang) {
// On prend les deux premiers caractères (ex: 'en-US' devient 'en')
const normalizedHtmlLang = htmlLang.toLowerCase().substring(0, 2);
if (normalizedHtmlLang === 'en') {
langCode = 'en';
}
} else {
// 2. Si l'attribut 'lang' est manquant, utilise la langue du navigateur
const userLang = navigator.language || navigator.userLanguage;
if (userLang && userLang.toLowerCase().startsWith('en')) {
langCode = 'en';
}
}
return MESSAGES[langCode];
}
// --- FIN MESSAGES ET TRADUCTIONS ---
/**
* Convertit une chaîne Base64 URL Safe en Uint8Array.
* Nécessaire pour passer la clé VAPID publique à pushManager.subscribe().
* @param {string} base64String
* @returns {Uint8Array}
*/
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/**
* Fonction générique pour basculer la visibilité d'un menu déroulant.
* @param {HTMLElement} button - Le bouton qui déclenche l'action.
* @param {HTMLElement} menu - Le menu à afficher/masquer.
*/
function toggleMenu(button, menu) {
if (!button || !menu) return;
const isExpanded = button.getAttribute('aria-expanded') === 'true' || false;
button.setAttribute('aria-expanded', !isExpanded);
menu.classList.toggle('hidden');
}
/**
* Fonction d'initialisation pour les composants qui DOIVENT être réinitialisés
* après un chargement Turbo (comme les compteurs d'articles, les états initiaux).
* Le menu mobile et le panier sont gérés par délégation d'événements.
*/
function initializeUI() {
// Réinitialisation des états des menus cachés après un chargement Turbo
document.querySelectorAll('#mobile-menu, #userMenuDesktop, #userMenuMobile').forEach(menu => {
if (!menu.classList.contains('hidden')) {
menu.classList.add('hidden');
}
});
document.querySelectorAll('#mobileMenuButton, #userMenuButtonDesktop, #userMenuButtonMobile').forEach(button => {
button.setAttribute('aria-expanded', 'false');
});
// --- 2. Gestion du Panier Latéral (Off-Canvas) ---
const cartSidebar = document.getElementById('cartSidebar');
const cartBackdrop = document.getElementById('cartBackdrop');
const closeCartButton = document.getElementById('closeCartButton');
// Mettez les fonctions ici pour qu'elles soient toujours définies si les éléments existent
if (cartSidebar && cartBackdrop && closeCartButton) {
function openCart() {
document.body.style.overflow = 'hidden';
cartBackdrop.classList.remove('hidden');
cartSidebar.classList.remove('translate-x-full');
cartSidebar.classList.add('translate-x-0');
}
function closeCart() {
document.body.style.overflow = '';
cartSidebar.classList.remove('translate-x-0');
cartSidebar.classList.add('translate-x-full');
setTimeout(() => {
cartBackdrop.classList.add('hidden');
}, 300);
}
// Stocker les fonctions dans une variable globale accessible par l'écouteur du document
window.openCart = openCart;
window.closeCart = closeCart;
} else {
// Sécurité si les éléments du panier n'existent pas
window.openCart = null;
window.closeCart = null;
}
// --- 3. Logique Panier Mock (Affichage du compteur) ---
function updateCartDisplay(count) {
const desktopCounter = document.getElementById('cartCountDesktop');
const mobileCounter = document.getElementById('cartCountMobile');
if (desktopCounter) desktopCounter.textContent = count;
if (mobileCounter) mobileCounter.textContent = count;
}
// Simuler un panier non-vide au chargement (Mettre 0 pour un panier vide réel)
updateCartDisplay(0);
// --- 4. Vérification de l'abonnement push au chargement ---
if ('Notification' in window && Notification.permission === 'granted') {
subscribeAndSave();
}
}
/**
* Tente d'abonner l'utilisateur aux notifications push via le Service Worker
* et envoie l'objet d'abonnement au backend (/notificationSub).
*/
async function subscribeAndSave() {
if (!('Notification' in window) || Notification.permission !== 'granted') {
console.log("Les notifications ne sont pas supportées ou la permission n'est pas accordée.");
return;
}
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.error("Le Service Worker ou PushManager n'est pas disponible pour l'abonnement.");
return;
}
try {
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
// 1. Si aucun abonnement n'existe, on essaie d'en créer un.
if (!subscription) {
console.log("Aucun abonnement existant trouvé. Tentative de nouvel abonnement...");
const applicationServerKey = urlBase64ToUint8Array(VAPID_PUBLIC_KEY);
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
if (!subscription) {
console.error("Échec de la création de l'abonnement. Le Service Worker est-il correctement enregistré et la clé VAPID valide ?");
return;
}
} else {
console.log("Abonnement push existant trouvé. Vérification/Mise à jour auprès du serveur.");
}
// 2. Envoi (ou mise à jour) de l'abonnement au backend
const payload = { subscription: subscription.toJSON() };
const response = await fetch('/notificationSub', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
console.log("-> Abonnement aux notifications sauvegardé (ou vérifié) avec succès sur le serveur. <-");
} else {
console.error("-> Erreur lors de la sauvegarde de l'abonnement:", response.status, response.statusText);
}
} catch (error) {
console.error("Erreur lors de l'obtention de l'abonnement ou de l'enregistrement:", error);
}
}
/**
* Tente de demander la permission si nécessaire et d'appeler l'abonnement.
* (Utilisée par le clic du bandeau)
*/
async function promptForPermissionAndSubscribe() {
if (!('Notification' in window)) {
console.error("Les Notifications ne sont pas supportées par ce navigateur.");
return;
}
// Demander la permission
try {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log("-> Permission de notification accordée. Lancement de l'abonnement. <-");
await subscribeAndSave();
} else {
console.log(`-> Permission de notification refusée ou ignorée (${permission}). <-`);
}
} catch (error) {
console.error("Erreur lors de la demande de permission:", error);
}
}
// ====================================================================
// --- NOUVELLE FONCTION DE DÉTECTION POUR PAGESPEED/LIGHTHOUSE ---
// ====================================================================
/**
* Vérifie si le script est exécuté par un User Agent de test (e.g., Lighthouse/PageSpeed)
* @returns {boolean} True si c'est un User Agent de test.
*/
function isPerformanceTestAgent() {
const ua = navigator.userAgent;
// Vérifie les User Agents connus des outils de performance
return ua.includes('Lighthouse') || ua.includes('Chrome-Lighthouse') || ua.includes('PageSpeed') || ua.includes('moto g power') || ua.includes('Intel Mac OS X 10_15_7');
}
/**
* Affiche une petite carte de notification push temporaire en bas à gauche.
* N'affiche QUE si la permission n'est PAS accordée.
*/
function handleNotificationBanner() {
// 0. MASQUAGE POUR PAGESPEED
if (isPerformanceTestAgent()) {
console.log("Notification Banner skipped for performance test agent.");
return;
}
// --- FIN MASQUAGE ---
const BANNER_ID = 'notification-prompt-banner';
const DURATION_MS = 15000; // 15 secondes d'affichage
const M = getLanguageMessages(); // Récupère les messages traduits
// 1. NE PAS AFFICHER si la permission est déjà accordée
if ('Notification' in window && Notification.permission === 'granted') {
return;
}
// 2. Si le bandeau existe déjà, on quitte.
if (document.getElementById(BANNER_ID)) {
return;
}
// 3. Créer le conteneur du message
const banner = document.createElement('div');
banner.id = BANNER_ID;
banner.className = `fixed bottom-4 left-4 z-50 p-4 max-w-xs
bg-indigo-600 text-white rounded-xl shadow-2xl
transition-all duration-500 transform
opacity-0 translate-y-full
md:left-8 md:bottom-8`; // Style initial (masqué)
banner.innerHTML = `
<div class="flex items-start justify-between">
<p class="font-semibold text-sm leading-snug">
${M.notificationTitle}
</p>
<button id="closeNotificationBanner"
aria-label="${M.notificationClose}"
class="ml-3 -mt-1 p-1 rounded-full text-indigo-200 hover:text-white hover:bg-indigo-700 transition">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>
</button>
</div>
<p class="mt-1 text-xs text-indigo-200">
${M.notificationText}
</p>
<button id="activateNotifications"
class="mt-3 w-full text-center py-2 bg-white text-indigo-600 font-bold text-sm rounded-lg
shadow hover:bg-gray-100 transition transform hover:scale-[1.02]">
${M.notificationButton}
</button>
`;
document.body.appendChild(banner);
// 4. Fonctions d'animation et de gestion
const hideBanner = () => {
// Déclenche l'animation de disparition
banner.classList.remove('opacity-100', 'translate-y-0');
banner.classList.add('opacity-0', 'translate-y-full');
// Supprime après l'animation pour nettoyer le DOM
setTimeout(() => {
if (document.body.contains(banner)) {
document.body.removeChild(banner);
}
}, 600);
};
// Clic sur le bouton de fermeture
document.getElementById('closeNotificationBanner').addEventListener('click', () => {
hideBanner();
});
// Clic sur le bouton d'activation -> Logique Push
document.getElementById('activateNotifications').addEventListener('click', async () => {
await promptForPermissionAndSubscribe();
// Fermer le bandeau après l'interaction (que ce soit accordé ou refusé)
hideBanner();
});
// 5. Affichage et Timer
// Montre le bandeau (déclencher l'animation)
setTimeout(() => {
banner.classList.remove('opacity-0', 'translate-y-full');
banner.classList.add('opacity-100', 'translate-y-0');
}, 100);
// Cache le bandeau automatiquement après la durée définie
setTimeout(hideBanner, DURATION_MS);
}
/**
* Affiche le bandeau de consentement aux cookies en bas à droite s'il n'a jamais été accepté.
*/
function handleCookieBanner() {
// 0. MASQUAGE POUR PAGESPEED
if (isPerformanceTestAgent()) {
console.log("Cookie Banner skipped for performance test agent.");
return;
}
// --- FIN MASQUAGE ---
const BANNER_ID = 'cookie-banner';
const M = getLanguageMessages(); // Récupère les messages traduits
// 1. NE PAS AFFICHER si le consentement est déjà enregistré
if (localStorage.getItem(COOKIE_STORAGE_KEY) === 'true') {
return;
}
// 2. Si le bandeau existe déjà, on quitte.
if (document.getElementById(BANNER_ID)) {
return;
}
// 3. Créer le conteneur du message (position bottom-right)
const banner = document.createElement('div');
banner.id = BANNER_ID;
banner.className = `fixed bottom-4 right-4 z-50 p-4 max-w-sm
bg-white text-gray-800 rounded-xl shadow-2xl
transition-all duration-500 transform
opacity-0 translate-y-full
border border-gray-100
md:right-8 md:bottom-8`; // Style initial (masqué)
// Utilisation de M.cookieText, M.cookieLink et M.cookieButton
// MODIFICATION APPORTÉE ICI : Ajout de py-2 (padding vertical) au lien "En savoir plus"
banner.innerHTML = `
<p class="text-sm leading-relaxed">
${M.cookieText}
</p>
<div class="mt-4 flex justify-end items-center">
<a href="/cookies" class="py-2 font-medium text-indigo-600 hover:text-indigo-800 transition mr-4">
${M.cookieLink}
</a>
<button id="acceptCookies"
class="py-2 px-4 bg-indigo-600 text-white font-bold text-sm rounded-lg
shadow-md hover:bg-indigo-700 transition transform hover:scale-[1.02]">
${M.cookieButton}
</button>
</div>
`;
document.body.appendChild(banner);
// 4. Fonctions d'animation et de gestion
const hideBanner = () => {
// Déclenche l'animation de disparition
banner.classList.remove('opacity-100', 'translate-y-0');
banner.classList.add('opacity-0', 'translate-y-full');
// Supprime après l'animation
setTimeout(() => {
if (document.body.contains(banner)) {
document.body.removeChild(banner);
}
}, 600);
};
// Clic sur le bouton d'acceptation
document.getElementById('acceptCookies').addEventListener('click', () => {
localStorage.setItem(COOKIE_STORAGE_KEY, 'true');
hideBanner();
});
// 5. Affichage
// Montre le bandeau (déclencher l'animation)
setTimeout(() => {
banner.classList.remove('opacity-0', 'translate-y-full');
banner.classList.add('opacity-100', 'translate-y-0');
}, 100);
}
// --- INITIALISATION DES COMPOSANTS APRÈS TURBO/CHARGEMENT ---
document.addEventListener('DOMContentLoaded', ()=>{
// initializeUI appelle subscribeAndSave si la permission est accordée
initializeUI()
if (!isPerformanceTestAgent()) {
// Gère le bandeau de cookies (bottom-right)
handleCookieBanner()
}
/*if (!isPerformanceTestAgent()) {
var BASE_URL_WOOT = "https://app.chatwoot.com";
let script = document.createElement('script');
script.setAttribute('src', BASE_URL_WOOT + "/packs/js/sdk.js")
script.setAttribute('sync', true)
document.head.append(script)
script.onload = function () {
window.chatwootSDK.run({
websiteToken: '8SXvcdoWJVA77hug4mT5JhAP',
baseUrl: BASE_URL_WOOT
})
}
}
const env = document.querySelector('meta[name="env"]')
if(env.getAttribute('content') == "prod") {
if (typeof navigator.serviceWorker !== 'undefined') {
// Assurez-vous que le Service Worker est bien enregistré en mode prod
navigator.serviceWorker.register('sw.js')
}
}*/
});
document.addEventListener('turbo:load', () => {
initializeUI();
handleNotificationBanner();
handleCookieBanner(); // Doit être appelé à chaque chargement Turbo
});
// ====================================================================
// --- DÉLÉGATION D'ÉVÉNEMENTS (Gestion des clics une seule fois) ---
// ====================================================================
document.addEventListener('click', (event) => {
const target = event.target;
// --- 1. GESTION DU MENU MOBILE (Burger) ---
const mobileMenuButton = document.getElementById('mobileMenuButton');
const mobileMenu = document.getElementById('mobile-menu');
// On vérifie si la cible cliquée est le bouton ou un de ses enfants
if (mobileMenuButton && mobileMenu && (target === mobileMenuButton || mobileMenuButton.contains(target))) {
event.preventDefault();
toggleMenu(mobileMenuButton, mobileMenu);
return;
}
// --- 2. GESTION DU MENU UTILISATEUR (Dropdown) ---
const userMenuButtonDesktop = document.getElementById('userMenuButtonDesktop');
const userMenuDesktop = document.getElementById('userMenuDesktop');
const userMenuButtonMobile = document.getElementById('userMenuButtonMobile');
const userMenuMobile = document.getElementById('userMenuMobile');
// Ouverture/Fermeture du menu utilisateur Desktop
if (userMenuButtonDesktop && userMenuDesktop && (target === userMenuButtonDesktop || userMenuButtonDesktop.contains(target))) {
event.preventDefault();
// S'assurer que les autres menus sont fermés
userMenuMobile.classList.add('hidden');
toggleMenu(userMenuButtonDesktop, userMenuDesktop);
return;
}
// Ouverture/Fermeture du menu utilisateur Mobile
if (userMenuButtonMobile && userMenuMobile && (target === userMenuButtonMobile || userMenuButtonMobile.contains(target))) {
event.preventDefault();
// S'assurer que les autres menus sont fermés
userMenuDesktop.classList.add('hidden');
toggleMenu(userMenuButtonMobile, userMenuMobile);
return;
}
// Fermeture des menus s'il y a un clic en dehors
const isClickInsideDesktopMenu = userMenuDesktop && (userMenuDesktop.contains(target) || userMenuButtonDesktop.contains(target));
const isClickInsideMobileMenu = userMenuMobile && (userMenuMobile.contains(target) || userMenuButtonMobile.contains(target));
if (userMenuDesktop && userMenuButtonDesktop && !isClickInsideDesktopMenu) {
userMenuDesktop.classList.add('hidden');
userMenuButtonDesktop.setAttribute('aria-expanded', 'false');
}
if (userMenuMobile && userMenuButtonMobile && !isClickInsideMobileMenu) {
userMenuMobile.classList.add('hidden');
userMenuButtonMobile.setAttribute('aria-expanded', 'false');
}
// --- 3. GESTION DE L'OUVERTURE ET FERMETURE DU PANIER ---
const openCartDesktop = document.getElementById('openCartDesktop');
const openCartMobile = document.getElementById('openCartMobile');
const closeCartButton = document.getElementById('closeCartButton');
const cartBackdrop = document.getElementById('cartBackdrop');
// Ouverture (Desktop ou Mobile)
if (window.openCart && (
(openCartDesktop && (target === openCartDesktop || openCartDesktop.contains(target))) ||
(openCartMobile && (target === openCartMobile || openCartMobile.contains(target)))
)) {
event.preventDefault();
window.openCart();
return;
}
// Fermeture (Bouton interne)
if (window.closeCart && closeCartButton && (target === closeCartButton || closeCartButton.contains(target))) {
event.preventDefault();
window.closeCart();
return;
}
// Fermeture (Cliquer sur le fond/backdrop)
if (window.closeCart && target === cartBackdrop) {
window.closeCart();
return;
}
});
// --- GESTION GLOBALE DE LA TOUCHE ESC (Une seule fois) ---
document.addEventListener('keydown', (event) => {
const cartSidebar = document.getElementById('cartSidebar');
// Fermer le panier
if (cartSidebar && window.closeCart && event.key === 'Escape' && !cartSidebar.classList.contains('translate-x-full')) {
window.closeCart();
return;
}
// Fermer les menus utilisateur
const userMenuDesktop = document.getElementById('userMenuDesktop');
const userMenuButtonDesktop = document.getElementById('userMenuButtonDesktop');
const userMenuMobile = document.getElementById('userMenuMobile');
const userMenuButtonMobile = document.getElementById('userMenuButtonMobile');
if (event.key === 'Escape') {
if (userMenuDesktop && !userMenuDesktop.classList.contains('hidden')) {
toggleMenu(userMenuButtonDesktop, userMenuDesktop);
return;
}
if (userMenuMobile && !userMenuMobile.classList.contains('hidden')) {
toggleMenu(userMenuButtonMobile, userMenuMobile);
return;
}
}
});

1
assets/app.scss Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -1,15 +0,0 @@
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"entrypoints": []
}

View File

@@ -1,81 +0,0 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';

View File

@@ -1,16 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}

View File

@@ -1,5 +0,0 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);

View File

@@ -1,3 +0,0 @@
body {
background-color: skyblue;
}

View File

@@ -1,18 +0,0 @@
services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
mailer:
image: axllent/mailpit
ports:
- "1025"
- "8025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ###

View File

@@ -1,25 +0,0 @@
services:
###> doctrine/doctrine-bundle ###
database:
image: postgres:${POSTGRES_VERSION:-16}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
POSTGRES_USER: ${POSTGRES_USER:-app}
healthcheck:
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/postgresql/data:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###

View File

@@ -6,43 +6,81 @@
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-dom": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.7",
"doctrine/orm": "^3.5",
"phpdocumentor/reflection-docblock": "^5.6",
"ext-libxml": "*",
"ext-zip": "*",
"chillerlan/php-qrcode": ">=5.0.5",
"cocur/slugify": ">=4.6",
"doctrine/dbal": "^3.10.3",
"doctrine/doctrine-bundle": "^2.18.1",
"doctrine/doctrine-migrations-bundle": "^3.7.0",
"doctrine/orm": "^3.5.7",
"docusealco/docuseal-php": "^1.0.5",
"endroid/qr-code": ">=6.0.9",
"exbil/mailcow-php-api": ">=0.15.0",
"fpdf/fpdf": ">=1.86",
"google/apiclient": "^2.18.4",
"google/cloud": "^0.296.0",
"healey/robots": "^1.0.1",
"imagine/imagine": "^1.5",
"io-developer/php-whois": ">=4.1.10",
"knplabs/knp-paginator-bundle": "^6.9.1",
"lasserafn/php-initial-avatar-generator": "^4.5",
"league/flysystem-aws-s3-v3": "^3.30.1",
"league/flysystem-bundle": "^3.6",
"liip/imagine-bundle": "^2.15",
"lufiipe/insee-sierene": ">=1",
"minishlink/web-push": "^9.0.3",
"mittwald/vault-php": "^3.0.2",
"mobiledetect/mobiledetectlib": "^4.8.09",
"nelmio/cors-bundle": "^2.6",
"ovh/ovh": ">=3.5",
"pear/net_dns2": ">=2.0.7",
"phpdocumentor/reflection-docblock": "^5.6.4",
"phpoffice/phpspreadsheet": ">=5.3",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.4.*",
"symfony/asset-mapper": "7.4.*",
"symfony/console": "7.4.*",
"symfony/doctrine-messenger": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/intl": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.0|^4.0",
"symfony/notifier": "7.4.*",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^2.31",
"symfony/string": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/ux-turbo": "^2.31",
"symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*",
"symfony/yaml": "7.4.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
"presta/sitemap-bundle": "^4.2",
"sentry/sentry-symfony": "^5.6",
"setasign/fpdi": "^2.6.4",
"spatie/mjml-php": "^1.2.5",
"stancer/stancer": ">=2.0.1",
"symfony/amazon-mailer": "7.3.*",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2.10.0",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/intl": "7.3.*",
"symfony/mailer": "7.3.*",
"symfony/mime": "7.3.*",
"symfony/monolog-bundle": "^3.10",
"symfony/notifier": "7.3.*",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/redis-messenger": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/string": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/uid": "7.3.*",
"symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*",
"symfony/yaml": "7.3.*",
"tecnickcom/tcpdf": "^6.10.1",
"twig/extra-bundle": "^3.22.1",
"twig/intl-extra": "^3.22.1",
"twig/twig": "^3.22",
"vich/uploader-bundle": "^2.8.1",
"web-auth/webauthn-lib": ">=5.2.2"
},
"config": {
"allow-plugins": {
@@ -91,17 +129,19 @@
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
"allow-contrib": true,
"require": "7.3.*"
}
},
"require-dev": {
"phpunit/phpunit": "^12.5",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.0",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
"fakerphp/faker": "^1.24.1",
"phpunit/phpunit": "^12.4.4",
"rector/rector": "^2.2.8",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.65",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
}
}

20604
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,15 @@ return [
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
Presta\SitemapBundle\PrestaSitemapBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
];

View File

@@ -1,19 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# Nom unique de votre application : utilisé pour calculer des espaces de noms stables pour les clés de cache.
# Ceci est CRUCIAL pour éviter les collisions de clés si plusieurs applications partagent le même serveur de cache (ex: Redis).
# Décommentez et remplacez par une valeur unique à votre projet (ex: "mon_entreprise/mon_app")
prefix_seed: 'e-page' # <-- REMPLACEZ CECI PAR UN NOM UNIQUE À VOTRE PROJET
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null
# En production, utilisez un adaptateur de cache rapide et performant comme Redis.
# Assurez-vous que votre serveur Redis est accessible.
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_DSN)%'
# Vous pouvez également optimiser les pools personnalisés pour la production si besoin.
pools:
my.user_data_cache:
adapter: cache.adapter.redis
my.api_data_cache:
adapter: cache.adapter.redis
vite_cache_pool:
adapter: cache.adapter.redis

View File

@@ -0,0 +1,7 @@
# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md
flysystem:
storages:
default.storage:
adapter: 'local'
options:
directory: '%kernel.project_dir%/var/storage/default'

View File

@@ -2,11 +2,20 @@
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
http_cache:
enabled: true
default_ttl: 3600
stale_while_revalidate: 3600
stale_if_error: 3600
session:
name: crm_session
cookie_lifetime: 3600
cookie_secure: true
#esi: true
#fragments: true
trusted_proxies: '103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,104.16.0.0/13,104.24.0.0/14,108.162.192.0/18,131.0.72.0/22,141.101.64.0/18,162.158.0.0/15,172.64.0.0/13,173.245.48.0/20,188.114.96.0/20,190.93.240.0/20,197.234.240.0/22,198.41.128.0/17'
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]
when@test:
framework:

View File

@@ -0,0 +1,11 @@
services:
Google\Client:
class: Google\Client
calls:
# Authentication with "API key"
- [setDeveloperKey, ['%env(GOOGLE_API_KEY)%']]
# Authentication with "OAuth 2.0" using Client ID & Secret
- [setClientId, ['%env(GOOGLE_CLIENT_ID)%']]
- [setClientSecret, ['%env(GOOGLE_CLIENT_SECRET)%']]
# Authentication with "OAuth 2.0" or "Service account" using JSON
- [setAuthConfig, ['%env(resolve:GOOGLE_AUTH_CONFIG)%']]

View File

@@ -0,0 +1,16 @@
liip_imagine:
driver: "gd"
twig:
mode: lazy
filter_sets:
webp:
quality: 85
format: 'webp'
filters:
strip: ~
logo:
quality: 85
format: 'webp'
filters:
strip: ~
thumbnail: { size: [ 99, 56 ], mode: inset }

View File

@@ -1,29 +1,8 @@
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 3
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
default_bus: messenger.bus.default
buses:
messenger.bus.default: []
async: "%env(MESSENGER_TRANSPORT_DSN)%"
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
# Route your messages to the transports
# 'App\Message\YourMessage': async

View File

@@ -10,6 +10,14 @@ when@dev:
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
@@ -33,23 +41,11 @@ when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
formatter: monolog.formatter.json
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json
channels: ["!event", "!doctrine", "!console"]

View File

@@ -0,0 +1,10 @@
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null

View File

@@ -0,0 +1,3 @@
presta_sitemap:
sitemap_file_prefix: 'sitemap'
timetolive: 3600

View File

@@ -1,39 +1,48 @@
# config/packages/security.yaml
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
users_in_memory: { memory: null }
# 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
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: users_in_memory
provider: app_account_provider # Utilise le provider que nous avons défini ci-dessus
user_checker: App\Security\UserChecker
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)
entry_point: App\Security\AuthenticationEntryPoint
custom_authenticator:
- App\Security\LoginFormAuthenticator
logout:
target: app_logout
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
# 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.
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
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
role_hierarchy:
ROLE_ROOT: [ROLE_ADMIN] # ROLE_ROOT inclut ROLE_ADMIN, qui à son tour inclut ROLE_ARTEMIS
# Note: Only the *first* matching rule is applied
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:
security:
password_hashers:
# Password hashers are resource-intensive by design to ensure security.
# In tests, it's safe to reduce their cost to improve performance.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
- { path: ^/admin, roles: [ROLE_ADMIN] }
- { path: ^/console, roles: [ROLE_COSPLAY] }
- { path: ^/, roles: PUBLIC_ACCESS } # Toutes les autres pages nécessitent une authentification complète

View File

@@ -0,0 +1,36 @@
when@prod:
sentry:
dsn: '%env(SENTRY_DSN)%'
options:
# Add request headers, cookies, IP address and the authenticated user
# see https://docs.sentry.io/platforms/php/data-management/data-collected/ for more info
# send_default_pii: true
ignore_exceptions:
- 'Symfony\Component\ErrorHandler\Error\FatalError'
- 'Symfony\Component\Debug\Exception\FatalErrorException'
#
# # If you are using Monolog, you also need this additional configuration to log the errors correctly:
# # https://docs.sentry.io/platforms/php/guides/symfony/#monolog-integration
# register_error_listener: false
# register_error_handler: false
#
# monolog:
# handlers:
# # Use this only if you don't want to use structured logging and instead receive
# # certain log levels as errors.
# sentry:
# type: sentry
# level: !php/const Monolog\Logger::ERROR
# hub_id: Sentry\State\HubInterface
# fill_extra_context: true # Enables sending monolog context to Sentry
# process_psr_3_messages: false # Disables the resolution of PSR-3 placeholders
#
# # Use this for structured log integration
# sentry_logs:
# type: service
# id: Sentry\SentryBundle\Monolog\LogsHandler
#
# services:
# Sentry\SentryBundle\Monolog\LogsHandler:
# arguments:
# - !php/const Monolog\Logger::INFO

View File

@@ -1,5 +1,5 @@
framework:
default_locale: en
default_locale: fr
translator:
default_path: '%kernel.project_dir%/translations'
providers:

View File

@@ -1,6 +1,7 @@
twig:
file_name_pattern: '*.twig'
form_themes:
- 'form_tailwind.twig'
when@test:
twig:
strict_variables: true

View File

@@ -1,4 +0,0 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
csrf_protection:
check_header: true

View File

@@ -0,0 +1,4 @@
vich_uploader:
db_driver: orm
mappings:

View File

@@ -1,11 +1,9 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
# bin/console debug:router
controllers:
resource: routing.controllers
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
presta_sitemap:
resource: "@PrestaSitemapBundle/config/routing.yml"

View File

@@ -0,0 +1,2 @@
_liip_imagine:
resource: "@LiipImagineBundle/Resources/config/routing.yaml"

View File

@@ -0,0 +1,2 @@
presta_sitemap:
resource: "@PrestaSitemapBundle/config/routing.yml"

View File

@@ -1,8 +1,5 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# See also https://symfony.com/doc/current/service_container/import.html
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
@@ -19,5 +16,7 @@ services:
App\:
resource: '../src/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Twig\ViteAssetExtension:
arguments:
$manifest: '%kernel.project_dir%/public/build/.vite/manifest.json'
$cache: '@vite_cache_pool'

177
docker-compose.yml Normal file
View File

@@ -0,0 +1,177 @@
services:
# --- Service PHP ---
# Conteneur pour exécuter l'application Symfony (requêtes web)
php:
build:
context: .
dockerfile: ./docker/php/Dockerfile
args:
# Utilise l'UID/GID de l'hôte pour éviter les problèmes de permissions
UID: ${UID:-1000}
GID: ${GID:-1000}
container_name: crm_php
environment:
- XDEBUG_MODE=coverage
volumes:
# Monte le code source de l'application dans le conteneur
- ./:/srv/app
# Monte le socket Docker pour permettre l'exécution de commandes docker depuis le conteneur
- /var/run/docker.sock:/var/run/docker.sock
- ./coverage:/opt/phpstorm-coverage
- ./.coverage:/opt/phpstorm-coverage
depends_on:
# S'assure que la base de données et Redis sont démarrés avant PHP
- db
- redis
networks:
- crm_network # Assignation au réseau commun
# --- Service Worker pour Symfony Messenger ---
# Conteneur dédié à l'exécution des messages en arrière-plan
messenger-worker:
build:
context: .
dockerfile: ./docker/php/Dockerfile
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
container_name: crm_messenger_worker
# Commande pour lancer le worker. 'async' est le nom du transport par défaut.
command: php bin/console messenger:consume async --memory-limit=128M --time-limit=3600
volumes:
- ./:/srv/app
# Monte aussi le socket Docker ici pour la cohérence
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- db
- redis
networks:
- crm_network # Assignation au réseau commun
# --- Service Frontend (Bun + Vite) ---
# Conteneur pour compiler les assets JS/CSS en développement
bun:
image: oven/bun:1-slim
container_name: crm_bun
# Exécute les commandes avec l'utilisateur de l'hôte pour éviter les problèmes de permissions sur node_modules
user: "${UID:-1000}:${GID:-1000}"
working_dir: /usr/src/app
volumes:
- ./:/usr/src/app
ports:
# Port par défaut du serveur de développement Vite
- "5173:5173"
# Lance le serveur de développement Vite défini dans package.json
command: bun run dev
networks:
- crm_network # Assignation au réseau commun
# --- Service Serveur Web (Caddy) ---
# Serveur web moderne qui sert l'application et gère le PHP-FPM
caddy:
image: caddy:2-alpine
container_name: crm_caddy
ports:
# Mappe le port 8000 de l'hôte au port 80 du conteneur
- "8000:80"
volumes:
# Monte le fichier de configuration de Caddy
- ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile
# Monte le code source pour servir les fichiers statiques
- ./:/srv/app
depends_on:
- php
networks:
- crm_network # Assignation au réseau commun
# --- Service Base de Données principale (PostgreSQL) ---
db:
image: postgres:16-alpine
container_name: crm_db
ports:
- "5432:5432"
environment:
POSTGRES_DB: app_db
POSTGRES_USER: symfony_user
POSTGRES_PASSWORD: ChangeMeInProd!
volumes:
# Volume nommé pour la persistance des données
- db_data:/var/lib/postgresql/data
networks:
- crm_network # Assignation au réseau commun
# --- Service Cache/Messenger (Redis) ---
redis:
image: redis:7-alpine
container_name: crm_redis
networks:
- crm_network # Assignation au réseau commun
# --- Service de Test d'Emails (MailHog) ---
# Intercepte tous les emails envoyés en développement
mailhog:
image: mailhog/mailhog:latest
container_name: crm_mailhog
ports:
# Port 1025 pour le serveur SMTP factice
- "1025:1025"
# Port 8025 pour l'interface web de MailHog
- "8025:8025"
networks:
- crm_network # Assignation au réseau commun
# --- Service de Stockage Fichiers (MinIO) ---
# Fournit une API compatible S3 pour le stockage de fichiers
minio:
image: minio/minio:RELEASE.2025-02-03T21-03-04Z
container_name: crm_minio
ports:
# Port 9000 pour l'API S3
- "9000:9000"
# Port 9001 pour la console web de MinIO
- "9001:9001"
environment:
MINIO_ROOT_USER: minio_user
MINIO_ROOT_PASSWORD: ChangeMeInProd!
volumes:
# Volume nommé pour la persistance des fichiers
- minio_data:/data
# Commande pour démarrer MinIO et lancer la console sur le bon port
command: server /data --console-address ":9001"
networks:
- crm_network # Assignation au réseau commun
# --- Service de Gestion des Secrets (HashiCorp Vault) ---
vault:
image: hashicorp/vault:latest
container_name: crm_vault
ports:
- "8210:8200" # Mappe le port 8210 de l'hôte au port 8200 du conteneur Vault
- "8211:8201" # Mappe le port 8210 de l'hôte au port 8200 du conteneur Vault
- "8212:8202" # Mappe le port 8212 de l'hôte au port 8200 du conteneur Vault
volumes:
# Volume pour la persistance des données
- vault_data:/vault
# Volume pour monter notre fichier de configuration
environment:
VAULT_DEV_ROOT_TOKEN_ID: myroot
VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8201
VAULT_LOCAL_CONFIG: '{"storage": {"file": {"path": "/vault/file"}}, "listener": [{"tcp": { "address": "0.0.0.0:8200", "tls_disable": true}}], "default_lease_ttl": "168h", "max_lease_ttl": "720h", "ui": true,"disable_mlock": false}'
# Lance Vault en mode serveur avec notre fichier de configuration
cap_add:
- IPC_LOCK
command: "server -dev"
networks:
- crm_network # Assignation au réseau commun
# Définition des volumes pour la persistance des données
volumes:
db_data: # Pour la base de données principale de Symfony
minio_data: # Pour le stockage de fichiers MinIO
vault_data: # Pour les données de HashiCorp Vault
# Définition des réseaux
networks:
crm_network: # Définition du réseau commun
driver: bridge # Le type de driver le plus courant

123
docker/actions/Dockerfile Normal file
View File

@@ -0,0 +1,123 @@
# Use the official Debian 12.11 (Bookworm) image as the base
FROM debian:12.11
# Set environment variables to prevent interactive prompts during apt operations
ENV DEBIAN_FRONTEND=noninteractive
# Update the package list and install necessary dependencies for adding Node.js and PHP repositories
# curl is needed to download the NodeSource setup script, Bun install script, and Composer installer
# gnupg is needed to handle GPG keys for apt repositories
# ca-certificates is needed for secure connections
# apt-transport-https is needed for apt to fetch packages over HTTPS
# unzip and tar are often required for Bun's installation process
# lsb-release is needed for add-apt-repository (which is not used directly, but useful for detecting distro)
# dirmngr is needed for adding GPG keys
# wget is needed to download the PHP repository GPG key
RUN apt-get update && \
apt-get install -y curl gnupg ca-certificates apt-transport-https unzip tar lsb-release dirmngr wget && \
rm -rf /var/lib/apt/lists/*
# Add NodeSource GPG key for Node.js 23.x repository
# The NodeSource setup script adds the repository and imports the GPG key.
# We're specifically targeting Node.js 23.x.
RUN curl -fsSL https://deb.nodesource.com/setup_23.x | bash -
# Install Node.js and npm from the NodeSource repository
# nodejs package includes both Node.js runtime and npm (Node Package Manager)
RUN apt-get update && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
# Install Bun
# This command downloads and executes the official Bun installation script.
# It installs Bun globally.
RUN curl -fsSL https://bun.sh/install | bash
# Add Bun to the PATH for non-interactive shells and subsequent commands
# The Bun installer typically adds it to ~/.bashrc or similar, but for Docker,
# we need to ensure it's in the system-wide PATH or explicitly sourced.
# This line appends the Bun binary directory to the PATH environment variable.
ENV PATH="/root/.bun/bin:$PATH"
# Add Ondrej's PHP repository for Debian 12 (Bookworm)
# This repository provides up-to-date PHP versions.
RUN echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/sury-php.list && \
wget -qO - https://packages.sury.org/php/apt.gpg | apt-key add -
# Install PHP 8.3 and common extensions
# php8.3-cli: Command Line Interface
# php8.3-fpm: FastCGI Process Manager (for web servers like Nginx/Apache)
# php8.3-mysql: MySQL database extension
# php8.3-curl: cURL extension for making HTTP requests
# php8.3-mbstring: Multibyte string functions
# php8.3-xml: XML extension
# php8.3-zip: Zip archive extension (already present, but good to ensure)
# php8.3-gd: GD extension (for JPEG, WebP, etc.)
# php8.3-pdo: PDO (PHP Data Objects) extension
# php8.3-pgsql: PostgreSQL PDO driver
# php8.3-gmp: GNU Multiple Precision arithmetic functions
# php8.3-bcmath: Arbitrary precision mathematics
# php8.3-intl: Internationalization extension
# php8.3-redis: Redis extension
# php8.3-excimer: Excimer extension (for profiling)
# php8.3-xdebug: Xdebug extension (for debugging and profiling)
RUN apt-get update && \
apt-get install -y php8.3 php8.3-cli php8.3-fpm php8.3-mysql php8.3-curl php8.3-mbstring php8.3-xml php8.3-zip php8.3-gd php8.3-pdo php8.3-pgsql php8.3-gmp php8.3-bcmath php8.3-intl php8.3-redis php8.3-excimer php8.3-xdebug && \
rm -rf /var/lib/apt/lists/*
# Install Composer
# Download the Composer installer script
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Configure Xdebug
# Create a new INI file for Xdebug configuration.
# Set xdebug.mode to 'develop,debug' for development and debugging features.
# Set xdebug.start_with_request to 'yes' to always start Xdebug on every request.
# Set xdebug.client_host to 'host.docker.internal' for Docker Desktop compatibility
# This allows Xdebug to connect back to the host machine's IDE.
RUN echo "zend_extension=xdebug" > /etc/php/8.3/mods-available/xdebug.ini && \
echo "xdebug.mode=develop,debug" >> /etc/php/8.3/mods-available/xdebug.ini && \
echo "xdebug.start_with_request=yes" >> /etc/php/8.3/mods-available/xdebug.ini && \
echo "xdebug.client_host=host.docker.internal" >> /etc/php/8.3/mods-available/xdebug.ini
# --- Install Docker into the image ---
# Add Docker's official GPG key
RUN install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
chmod a+r /etc/apt/keyrings/docker.gpg
# Add the Docker repository to Apt sources
RUN echo \
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
# Update apt package index and install Docker Engine, CLI, containerd, and Docker Compose plugin
RUN apt-get update && \
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && \
rm -rf /var/lib/apt/lists/*
# Verify Node.js, npm, Bun, PHP, Composer, and Docker installations
RUN node -v
RUN npm -v
RUN bun -v
RUN php -v
RUN composer -v
# Set a working directory (optional, but good practice for applications)
WORKDIR /app
# You can add your application code here, for example:
# COPY . /app
# Install dependencies using Bun
# This command assumes you have a package.json or bun.lockb file in your /app directory.
# If you don't have one, this command will likely fail or do nothing.
# RUN bun install
# EXPOSE 3000
# CMD ["node", "your-app.js"]
# Default command if no other command is specified when running the container
# This will keep the container running and allow you to exec into it.
CMD ["node"]

46
docker/caddy/Caddyfile Normal file
View File

@@ -0,0 +1,46 @@
# ./docker/caddy/Caddyfile
# Nous écoutons sur le port 80 (qui est mappé depuis le port 8000 de l'hôte)
:80 {
# La racine de votre application Symfony
root * /srv/app/public
# Activation de la compression (optionnel)
encode gzip zstd
# Gère les assets statiques directement (améliore les performances)
file_server
# Passe toutes les requêtes non résolues par file_server à Symfony (index.php)
php_fastcgi php:9000 { # 'php' est le nom de votre service PHP dans docker-compose
# Transmet les en-têtes X-Forwarded-* reçus de Nginx à PHP-FPM
# C'est CRUCIAL pour que Symfony détecte HTTPS
env HTTPS on # Simule que la connexion est HTTPS pour PHP
env HTTP_X_FORWARDED_PROTO {header.X-Forwarded-Proto} # Récupère le proto original de Nginx
env HTTP_X_FORWARDED_HOST {header.X-Forwarded-Host} # Récupère l'hôte original de Nginx
env HTTP_X_FORWARDED_PORT {header.X-Forwarded-Port} # Récupère le port original de Nginx
env HTTP_X_REAL_IP {header.X-Real-IP} # Récupère l'IP réelle du client (si Nginx la passe)
# Assurez-vous que l'adresse IP de Nginx est aussi trustée par Symfony
# via framework.trusted_proxies dans Symfony.
}
# Journalisation des accès (utile pour le débogage)
log {
output stdout
format json
}
handle_path /ts.js {
redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js
}
handle_path /tp.widget.bootstrap.min.js.map {
redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js.map
}
# Configuration des en-têtes CORS pour les requêtes OPTIONS du profiler
# Note: Caddy a une directive 'header' mais c'est pour les réponses.
# Pour gérer spécifiquement les OPTIONS en tant que preflight, c'est plus direct
# de le faire via php_fastcgi si Symfony peut le gérer, ou avec une directive
# 'handle' ou 'route' spécifique si Caddy doit répondre directement.
# Pour l'instant, faisons confiance à Symfony pour gérer les CORS via NelmioCorsBundle
# une fois que le protocole est correct.
}

91
docker/php/Dockerfile Normal file
View File

@@ -0,0 +1,91 @@
# Utiliser une image PHP 8.3 FPM (Ubuntu-based)
FROM php:8.3-fpm
# Arguments pour les permissions de fichiers
ARG UID
ARG GID
# Mettre à jour et installer les dépendances système requises
RUN apt-get update && apt-get install -y --no-install-recommends \
# Outils de compilation et dépendances pour PHP extensions
build-essential \
libpq-dev \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libwebp-dev \
libicu-dev \
# Dépendance pour GMP (bibliothèque de développement)
libgmp-dev \
# Outils généraux
zip \
unzip \
ffmpeg \
jq \
wget \
nodejs \
npm \
postgresql-client \
git \
# Nettoyage des listes de paquets pour réduire la taille de l'image
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Installer le client HashiCorp Vault
ENV VAULT_VERSION=1.17.1
RUN wget https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip -O vault.zip && \
unzip vault.zip -d /usr/local/bin && \
rm vault.zip
# Installer le client MinIO (mc)
RUN wget https://dl.min.io/client/mc/release/linux-amd64/mc -O /usr/local/bin/mc && \
chmod +x /usr/local/bin/mc
RUN npm install -g mjml
# Configurer et installer les extensions PHP
# Utilisation de -j$(nproc) pour paralléliser la compilation et accélérer le build
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j$(nproc) gd pdo pdo_pgsql zip gmp bcmath intl exif
# Installer Redis via pecl
RUN pecl install redis && docker-php-ext-enable redis
# Installer Excimer via pecl
RUN pecl install excimer && docker-php-ext-enable excimer
# Configuration et installation de Xdebug
# Utilisation de --no-install-recommends pour éviter l'installation de paquets inutiles
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& mkdir -p /usr/src/php/ext/xdebug \
&& git clone https://github.com/xdebug/xdebug.git /usr/src/php/ext/xdebug \
&& cd /usr/src/php/ext/xdebug \
&& phpize \
&& ./configure --enable-xdebug \
&& make && make install \
&& rm -rf /usr/src/php/ext/xdebug \
&& apt-get autoremove -y build-essential git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
COPY ./docker/php/custom.ini /usr/local/etc/php/conf.d/custom.ini
RUN echo "zend_extension=xdebug" > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
RUN echo "xdebug.mode=develop,debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
&& echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
&& echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
RUN mkdir -p /opt/phpstorm-coverage && \
chmod -R 777 /opt/phpstorm-coverage
# Installer Composer globalement
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
RUN mkdir -p /opt/phpstorm-coverage/ && chmod -R 777 /opt/phpstorm-coverage/
# Créer un utilisateur et un groupe non-root pour l'application
# Utilisation de useradd/groupadd pour les systèmes basés sur Debian/Ubuntu
RUN groupadd -g $GID appuser && \
useradd -u $UID -g appuser -ms /bin/bash appuser
# Changer pour l'utilisateur non-root
USER appuser
# Définir le répertoire de travail
WORKDIR /srv/app

2
docker/php/custom.ini Normal file
View File

@@ -0,0 +1,2 @@
upload_max_filesize=128M
post_max_size=128M

1
docker/vault/config.json Normal file
View File

@@ -0,0 +1 @@
{"storage": {"file": {"path": "/vault/file"}}, "listener": [{"tcp": { "address": "0.0.0.0:8200", "tls_disable": true}}], "default_lease_ttl": "168h", "max_lease_ttl": "720h", "ui": true}

View File

@@ -0,0 +1,4 @@
#!/bin/sh
# 'exec' remplace le processus du shell par celui de vault, ce qui est une bonne pratique.
exec vault server -dev

13
google.json Normal file
View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "smiling-gasket-478816-n2",
"private_key_id": "50be7ef1c73c674d110c849d70acc8b1ed5904b0",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDAiPtoiN8Dpdw0\n44dxFBIMx1+9bwiL8PXxQhMYHkhRk1GGIhd+hUkpcQn/87FqH6M2fTDq1U8BEmwb\nWznpSt0rx0JLwfPwdh2CLJ8iZ/ZieUBGg1bQITOlXwN8w4BS4jfEZ57jwjjIOO+3\neA1ib3Y4o0NOQCEjoOdw9XZgzSBKopjYtCx7ELKTzuK7FQjfxBhUeETA2ts0Qi6l\nA6psximWu+ZKvTX0wZC4CCECqKM0CuaKdKrotoysx2bKQLlTclLU60LSqUJX2JZE\nTcXT8NRRt6B58rEOoxcP2l5bJSySw5PTceRC1wbihihDazefBKbqZh/N3eMueY2C\nuN1nTdWjAgMBAAECggEANF6OE7b3AiUBKpmg448UAcnRLtOevYHDQ+Y3D15dSWMK\nz7NCNLXIWq9WivocDcOhP1b6EUYIAUCjiyVbMSud0SSX0cdltMhL6nZ8qn0gtVRJ\nuWRMHryJMbgQWMVMiD7j8FZHD6hqjXt/KKZM7bNnsqwlyIkE+o3vpff+RQJGgEYV\nBLkj81wdW1DMJI3LQoau9jKdMf5mLrDCWRDyEugLMnvAA9acNWoYiBMB6lxvCa3G\n12nPjeCRJXJf+/VT7M2mwHjWgajG8AyFECYRe8cNmvIAfH3GC1uQfyLw8kReoCBL\n1jAUNAUQAQbmGz1Z87/k20skZIkXsW4eG2W6C4+L0QKBgQDiWfXdT+78aPCFopQ0\nFtOwYQvqjwPjeMa5z0T736ZNX115MAbl4DlfyUEb0cvospOLUXTg6cVtJzWkMK0/\nrkh/i8EQ8OyyStZ/K/wFxnR4Vu+AeSpop4aOqNl/meW7bflITUt8dIs+PLjK+vLa\nQPxQoNDSlGrejPz7lEG8DR1nrQKBgQDZwRbSMQTOj4mOGtv/bsCmCQ6ctNF6sH80\nPdCsmaygLfaPWNQ9lTPdJd993Id2Fo0Itzkwy/Vb+0eFj4iBSIpskuZ7mrA7eypJ\nAR/WJGX3BYAvMKEvMn+KOwJczHzsU1083moKV73ldGMiQdfZOtmnqzWpXpgnCEki\nq0lpp/IcjwKBgEI4OVKuwOb9OGiQILWAfBvcuGS9xFB3FARmG/NoAbofDTSYFVyJ\nFZ/tO+wMm5APNlUK1pu6KHT/hJTtXLIFpdYSp7/yC/05IbmAv7Fc1tQh8t1uFTca\n06XGxiKrfmcwDD7Xxh655camcxWHBydM3cQk2BLTMtS7AIQFYpnGaHTdAoGAKkfA\nm51i9oyOQ+ZZMxaZF2QIz0qYpf7hJA6glvLbvtpN1nWD+FUhFd6Fr5WDQ92LEtco\npp3jjTGUKI2/DoM8RWqckAFwGIyIoFY0jUrR9Y2+3urNUTG36+obQlN+KhDhuLDi\n3BE/UO8xVHR+abJwkoq+x50TY/jK4o1pmrc+XmcCgYApPsls/sai1QbD6pQwQcwJ\nZtLBvFbykc79nZ8/5XHbrtsqWtCAju3RnxRZArIE3rCh2PWbPhWRq2q0sQFVSd4v\n1JLPD7OD9dLw63HG4ODD8E6Ih+xUDJuCOl9bYYJiBpXUx91tOdBFrJeB2HRWHNsH\nNnXhRIljkbVF/FFkvGxa3g==\n-----END PRIVATE KEY-----\n",
"client_email": "shop-api@smiling-gasket-478816-n2.iam.gserviceaccount.com",
"client_id": "101205257048462610951",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/shop-api%40smiling-gasket-478816-n2.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

69
makefile Normal file
View File

@@ -0,0 +1,69 @@
# --- Configuration ---
PHP_EXEC = docker compose exec php
CONSOLE = $(PHP_EXEC) php bin/console
# --- Scripts d'automatisation ---
# Note: Ces scripts sont complexes et utilisent '$$' pour échapper le '$' pour Make.
# --- Commandes Docker ---
.PHONY: pull build start stop restart logs logs-php
pull: ## Récupère les dernières images docker
@docker compose pull
build: ## Construit (ou reconstruit) les images docker du projet
@docker compose build
start: ## Démarre tous les services en mode détaché
@docker compose up -d # Ajout de -d pour le mode détaché par défaut
stop: ## Arrête tous les services
@docker compose down
restart: stop start ## Redémarre tous les services
logs: ## Affiche les logs de tous les services
@docker compose logs -f
logs-php: ## Affiche les logs du service PHP
@docker compose logs -f php
# --- Commandes d'Initialisation ---
init: ## Installation initiale du projet (sans Vault/MinIO)
@make pull
@make build
@make start
@make composer-install
@sleep 5
@make db-create
@make migrate
# --- Commandes Symfony & DB ---
.PHONY: db-create entity migration migrate composer-install deps
db-create: ## Crée la base de données
@$(CONSOLE) doctrine:database:create --if-not-exists
db-drop: ## Crée la base de données
@$(CONSOLE) doctrine:database:drop --force
entity: ## Lance la création/mise à jour d'une entité
@$(CONSOLE) make:entity
migration: ## Génère une nouvelle migration
@$(CONSOLE) make:migration
migrate: ## Applique les migrations
@$(CONSOLE) doctrine:migrations:migrate --no-interaction
composer-install: ## Installe les dépendances Composer
@$(PHP_EXEC) composer install
deps: composer-install ## Alias pour composer-install
dbtest_add: ## Crée la base de données
@$(CONSOLE) doctrine:database:create --env=test
dbtest_migrate: ## Crée la base de données
@$(CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
dbtest_remove: ## Crée la base de données
@$(CONSOLE) doctrine:database:drop --env=test --force
# --- Aide ---
.PHONY: help
help: ## Affiche cet écran d'aide
@echo "Commandes disponibles mainframe :"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
.DEFAULT_GOAL := help
gitlab_build:
docker build -t tools-registry.esy-web.dev/mainframe/mainframe:php docker/php
docker push tools-registry.esy-web.dev/mainframe/mainframe:php

View File

@@ -0,0 +1,63 @@
<?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 Version20251209163956 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('CREATE TABLE "account" (id SERIAL NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, uuid UUID NOT NULL, is_first_login BOOLEAN DEFAULT NULL, update_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, is_actif BOOLEAN DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_USERNAME ON "account" (username)');
$this->addSql('COMMENT ON COLUMN "account".update_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE account_login_register (id SERIAL NOT NULL, account_id INT DEFAULT NULL, login_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_agent VARCHAR(255) NOT NULL, ip VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_23AAA4819B6B5FBA ON account_login_register (account_id)');
$this->addSql('COMMENT ON COLUMN account_login_register.login_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE account_reset_password_request (id SERIAL NOT NULL, account_id INT DEFAULT NULL, token VARCHAR(255) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_E41866D09B6B5FBA ON account_reset_password_request (account_id)');
$this->addSql('COMMENT ON COLUMN account_reset_password_request.requested_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN account_reset_password_request.expires_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE messenger_messages (id BIGSERIAL NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(\'messenger_messages\', NEW.queue_name::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;');
$this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;');
$this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON messenger_messages FOR EACH ROW EXECUTE PROCEDURE notify_messenger_messages();');
$this->addSql('ALTER TABLE account_login_register ADD CONSTRAINT FK_23AAA4819B6B5FBA FOREIGN KEY (account_id) REFERENCES "account" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE account_reset_password_request ADD CONSTRAINT FK_E41866D09B6B5FBA FOREIGN KEY (account_id) REFERENCES "account" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
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_login_register DROP CONSTRAINT FK_23AAA4819B6B5FBA');
$this->addSql('ALTER TABLE account_reset_password_request DROP CONSTRAINT FK_E41866D09B6B5FBA');
$this->addSql('DROP TABLE "account"');
$this->addSql('DROP TABLE account_login_register');
$this->addSql('DROP TABLE account_reset_password_request');
$this->addSql('DROP TABLE messenger_messages');
}
}

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "e-cosplay",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@hotwired/stimulus": "^3.2.2",
"@tailwindcss/postcss": "^4.1.17",
"postcss": "^8.5.6",
"postcss-scss": "^4.0.9",
"rollup-plugin-javascript-obfuscator": "^1.0.4",
"sass": "^1.94.2",
"vite": "^7.2.4"
},
"dependencies": {
"@grafikart/drop-files-element": "^1.0.9",
"@hotwired/turbo": "^8.0.20",
"@preact/preset-vite": "^2.10.2",
"@tailwindcss/vite": "^4.1.17",
"autoprefixer": "^10.4.22",
"body-scroll-lock": "^4.0.0-beta.0",
"react-email-editor": "^1.7.11",
"sortablejs": "^1.15.6",
"tailwindcss": "^4.1.17",
"tom-select": "^2.4.3",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-favicon": "^1.0.8"
}
}

8
phpstan.dist.neon Normal file
View File

@@ -0,0 +1,8 @@
parameters:
level: 6
paths:
- bin/
- config/
- public/
- src/
- tests/

7
postcss.config.cjs Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
parser: 'postcss-scss',
plugins: {
'@tailwindcss/postcss': {},
'autoprefixer': {},
},
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Command;
use App\Entity\Account;
use App\Service\Generator\TempPasswordGenerator;
use App\Service\Mailer\Event\CreatedAdminEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[AsCommand(name: 'crm:admin')]
class AccountCommand extends Command
{
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly UserPasswordHasherInterface $userPasswordHasher,
private readonly EntityManagerInterface $entityManager,
?string $name = null
) {
parent::__construct($name);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title("Création d'un utilisateur administrateur");
$existingUser = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => "jovann@siteconseil.fr"]);
if (!$existingUser instanceof Account) {
$password = TempPasswordGenerator::generate();
$newUser = new Account();
$newUser->setRoles(['ROLE_ROOT']);
$newUser->setUuid(Uuid::v4());
$newUser->setIsActif(true);
$newUser->setIsFirstLogin(true);
$newUser->setEmail("jovann@siteconseil.fr");
$newUser->setUsername("Jovann");
$hashedPassword = $this->userPasswordHasher->hashPassword($newUser, $password);
$newUser->setPassword($hashedPassword);
$this->eventDispatcher->dispatch(new CreatedAdminEvent($newUser,$password));
$this->entityManager->persist($newUser);
$this->entityManager->flush();
$io->success("Utilisateur administrateur créé avec succès.");
} else {
$io->warning("Un utilisateur avec l'email existe déjà.");
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Controller;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
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 HomeController extends AbstractController
{
#[Route(path: '/', name: 'app_home', options: ['sitemap' => false], methods: ['GET'])]
public function index(AuthenticationUtils $authenticationUtils): Response
{
return $this->render('home.twig',[
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
#[Route(path: '/logout', name: 'app_logout', options: ['sitemap' => false], methods: ['GET','POST'])]
public function logout(): Response
{
}
#[Route(path: '/mot-de-passe-oublie', name: 'app_forgot_password', options: ['sitemap' => false], methods: ['GET','POST'])]
public function forgotPassword(Request $request,EventDispatcherInterface $eventDispatcher): Response
{
$requestPasswordRequest = new ResetPasswordEvent();
$form = $this->createForm(RequestPasswordRequestType::class,$requestPasswordRequest);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$eventDispatcher->dispatch($requestPasswordRequest);
return $this->redirectToRoute('app_forgot_password_sent');
}
return $this->render('security/forgot_password.twig', [
'form' => $form->createView(),
]);
}
#[Route(path: '/mot-de-passe-oublie/sent', name: 'app_forgot_password_sent', options: ['sitemap' => false], methods: ['GET','POST'])]
public function forgotPasswordSent(Request $request,EventDispatcherInterface $eventDispatcher): Response
{
return $this->render('security/forgot_password_success.twig', [
]);
}
#[Route(path: '/mot-de-passe-oublie/{id}/{token}', name: 'app_forgot_password_confirm', options: ['sitemap' => false], methods: ['GET','POST'])]
public function forgotPasswordConfirm(UserPasswordHasherInterface $userPasswordHasher,EventDispatcherInterface $eventDispatcher,Request $request,EntityManagerInterface $entityManager,string $id,string $token): Response
{
$errorMessage = "Requête non valide.";
if (!is_numeric($id)) {
$this->addFlash("error", $errorMessage);
return $this->redirectToRoute('app_forgot_password');
}
$account = $entityManager->getRepository(Account::class)->find((int)$id);
if (!$account instanceof Account) {
$this->addFlash("error", $errorMessage);
return $this->redirectToRoute('app_forgot_password');
}
$requestToken = $entityManager->getRepository(AccountResetPasswordRequest::class)->findOneBy([
'Account' => $account, // Assurez-vous que 'Account' est le nom correct de la propriété/colonne dans votre entité AccountResetPasswordRequest.
'token' => $token
]);
if (!$requestToken instanceof AccountResetPasswordRequest) {
$this->addFlash("error", $errorMessage);
return $this->redirectToRoute('app_forgot_password');
}
$now = new \DateTimeImmutable();
if ($requestToken->getExpiresAt() < $now) {
$this->addFlash("error", "Le lien de réinitialisation de mot de passe a expiré.");
return $this->redirectToRoute('app_forgot_password');
}
$event = new ResetPasswordConfirmEvent();
$form = $this->createForm(RequestPasswordConfirmType::class,$event);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$account->setPassword($userPasswordHasher->hashPassword($account,$event->getPassword()));
$entityManager->persist($account);
$entityManager->flush();
$this->addFlash("success", "Votre mot de passe a été mis à jour avec succès.");
return $this->redirectToRoute('app_home');
}
return $this->render('security/forgot-password-confirm.twig', [
'form' => $form->createView(),
'noIndex' => true,
'id' => $id,
'token' => $token,
'account' => $account,
]);
}
const SENTRY_HOST = '';
const SENTRY_PROJECT_IDS = [''];
#[Route('/uptime',name: 'app_uptime',options: ['sitemap' => false], methods: ['GET'])]
public function app_uptime(Request $request,HttpClientInterface $httpClient): Response
{
return $this->json([]);
}
#[Route('/tunnel',name: 'app_tunnel',options: ['sitemap' => false], methods: ['POST'])]
public function tunnel(Request $request,HttpClientInterface $httpClient): Response
{
$envelope = $request->getContent();
if (empty($envelope)) {
return $this->json([]);
}
try {
// 2. Extract the header piece (first line)
$pieces = explode("\n", $envelope, 2);
$piece = $pieces[0];
// 3. Parse the header (which is JSON)
$header = json_decode($piece, true);
if (!isset($header['dsn'])) {
throw new \Exception("Missing DSN in envelope header.");
}
// 4. Extract and validate DSN and Project ID
$dsnUrl = parse_url($header['dsn']);
$dsnHostname = $dsnUrl['host'] ?? null;
$dsnPath = $dsnUrl['path'] ?? '/';
// Remove leading/trailing slashes from the path to get the project_id
$projectId = trim($dsnPath, '/');
if ($dsnHostname !== self::SENTRY_HOST) {
throw new \Exception("Invalid sentry hostname: {$dsnHostname}");
}
if (empty($projectId) || !in_array($projectId, self::SENTRY_PROJECT_IDS)) {
throw new \Exception("Invalid sentry project id: {$projectId}");
}
// 5. Construct the upstream Sentry URL
$upstreamSentryUrl = "https://" . self::SENTRY_HOST . "/api/" . $projectId . "/envelope/";
// 6. Forward the request using an HTTP client (e.g., Guzzle)
$response = $httpClient->request("POST",$upstreamSentryUrl, [
'body' => $envelope,
'headers' => [
// Sentry expects this content type
'Content-Type' => 'application/x-sentry-envelope',
// Forward the content encoding if present, though often not needed
// 'Content-Encoding' => $request->headers->get('Content-Encoding'),
],
]);
// 7. Return the status from the upstream Sentry response
return new JsonResponse([], $response->getStatusCode());
} catch (\Exception $e) {
// Log the error for server-side debugging
error_log("Error tunneling to Sentry: " . $e->getMessage());
// Return a success status (200/202) or a non-specific 500 to the client.
// Returning a non-error status (like 200) is often preferred for tunnels
// to avoid triggering ad-blockers on failures.
return new JsonResponse([
'error' => 'An error occurred during tunneling.'
], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
}
}
}

248
src/Entity/Account.php Normal file
View File

@@ -0,0 +1,248 @@
<?php
namespace App\Entity;
use AllowDynamicProperties;
use App\Repository\AccountRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[AllowDynamicProperties] #[ORM\Entity(repositoryClass: AccountRepository::class)]
#[ORM\Table(name: '`account`')]
#[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
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
private ?string $username = null;
/**
* @var list<string> The user roles
*/
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private ?string $password = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(type: Types::GUID)]
private ?string $uuid = null;
#[ORM\Column(nullable: true)]
private ?bool $isFirstLogin = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updateAt;
/**
* @var Collection<int, AccountLoginRegister>
*/
#[ORM\OneToMany(targetEntity: AccountLoginRegister::class, mappedBy: 'account')]
private Collection $accountLoginRegisters;
#[ORM\Column(nullable: true)]
private ?bool $isActif = null;
public function __construct()
{
$this->accountLoginRegisters = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(string $username): static
{
$this->username = $username;
return $this;
}
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->username;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
/**
* @param list<string> $roles
*/
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
/**
* Ensure the session doesn't contain actual password hashes by CRC32C-hashing them, as supported since Symfony 7.3.
*/
#[\Deprecated]
public function eraseCredentials(): void
{
// @deprecated, to be removed when upgrading to Symfony 8
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getUuid(): ?string
{
return $this->uuid;
}
public function setUuid(string $uuid): static
{
$this->uuid = $uuid;
return $this;
}
public function isFirstLogin(): ?bool
{
return $this->isFirstLogin;
}
public function setIsFirstLogin(?bool $isFirstLogin): static
{
$this->isFirstLogin = $isFirstLogin;
return $this;
}
public function getUpdateAt(): ?\DateTimeImmutable
{
return $this->updateAt;
}
public function setUpdateAt(?\DateTimeImmutable $updateAt): static
{
$this->updateAt = $updateAt;
return $this;
}
public function serialize()
{
return serialize(array(
$this->id,
$this->email,
$this->username,
$this->avatarFileName,
));
}
public function unserialize(string $data)
{
list (
$this->id,
$this->email,
$this->username,
$this->avatarFileName,
) = unserialize($data);
}
/**
* @return Collection<int, AccountLoginRegister>
*/
public function getAccountLoginRegisters(): Collection
{
return $this->accountLoginRegisters;
}
public function addAccountLoginRegister(AccountLoginRegister $accountLoginRegister): static
{
if (!$this->accountLoginRegisters->contains($accountLoginRegister)) {
$this->accountLoginRegisters->add($accountLoginRegister);
$accountLoginRegister->setAccount($this);
}
return $this;
}
public function removeAccountLoginRegister(AccountLoginRegister $accountLoginRegister): static
{
if ($this->accountLoginRegisters->removeElement($accountLoginRegister)) {
// set the owning side to null (unless already changed)
if ($accountLoginRegister->getAccount() === $this) {
$accountLoginRegister->setAccount(null);
}
}
return $this;
}
public function isActif(): ?bool
{
return $this->isActif;
}
public function setIsActif(?bool $isActif): static
{
$this->isActif = $isActif;
return $this;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Entity;
use App\Repository\AccountLoginRegisterRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AccountLoginRegisterRepository::class)]
class AccountLoginRegister
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'accountLoginRegisters')]
private ?Account $account = null;
#[ORM\Column]
private ?\DateTimeImmutable $loginAt = null;
#[ORM\Column(length: 255)]
private ?string $userAgent = null;
#[ORM\Column(length: 255)]
private ?string $ip = null;
public function getId(): ?int
{
return $this->id;
}
public function getAccount(): ?Account
{
return $this->account;
}
public function setAccount(?Account $account): static
{
$this->account = $account;
return $this;
}
public function getLoginAt(): ?\DateTimeImmutable
{
return $this->loginAt;
}
public function setLoginAt(\DateTimeImmutable $loginAt): static
{
$this->loginAt = $loginAt;
return $this;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function setUserAgent(string $userAgent): static
{
$this->userAgent = $userAgent;
return $this;
}
public function getIp(): ?string
{
return $this->ip;
}
public function setIp(string $ip): static
{
$this->ip = $ip;
return $this;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Entity;
use App\Repository\AccountResetPasswordRequestRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AccountResetPasswordRequestRepository::class)]
class AccountResetPasswordRequest
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
private ?Account $Account = null;
#[ORM\Column(length: 255)]
private ?string $token = null;
#[ORM\Column]
private ?\DateTimeImmutable $requestedAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $expiresAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getAccount(): ?Account
{
return $this->Account;
}
public function setAccount(?Account $Account): static
{
$this->Account = $Account;
return $this;
}
/**
* @return string|null
*/
public function getToken(): ?string
{
return $this->token;
}
public function setToken(?string $token): static
{
$this->token = $token;
return $this;
}
public function getRequestedAt(): ?\DateTimeImmutable
{
return $this->requestedAt;
}
public function setRequestedAt(\DateTimeImmutable $requestedAt): static
{
$this->requestedAt = $requestedAt;
return $this;
}
public function getExpiresAt(): ?\DateTimeImmutable
{
return $this->expiresAt;
}
public function setExpiresAt(\DateTimeImmutable $expiresAt): static
{
$this->expiresAt = $expiresAt;
return $this;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Form;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class RequestPasswordConfirmType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('password', RepeatedType::class, [
'required' => true,
'type' => PasswordType::class,
'first_options' => [
'label' => 'Mot de passe',
],
'second_options' => [
'label' => 'Confirmer le mot de passe',
],
'invalid_message' => 'Les deux mots de passe doivent correspondre.',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ResetPasswordConfirmEvent::class,
]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Form;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class RequestPasswordRequestType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('email', EmailType::class, [
'required' => true,
'label' => 'Email de votre compte'
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ResetPasswordEvent::class,
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Repository;
use App\Entity\Account;
use App\Entity\AccountLoginRegister;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AccountLoginRegister>
*/
class AccountLoginRegisterRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AccountLoginRegister::class);
}
// /**
// * @return AccountLoginRegister[] Returns an array of AccountLoginRegister objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('a.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?AccountLoginRegister
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
public function lastLogin(Account $account)
{
return $this->createQueryBuilder('a')
->andWhere('a.account = :acc')
->setParameter('acc',$account)
->orderBy('a.loginAt','DESC')
->getQuery()->getResult();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Repository;
use App\Entity\Account;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<Account>
*/
class AccountRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Account::class);
}
/**
* Used to upgrade (rehash) the user's password automatically over time.
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof Account) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\AccountResetPasswordRequest;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AccountResetPasswordRequest>
*/
class AccountResetPasswordRequestRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AccountResetPasswordRequest::class);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
use Twig\Environment;
class AccessDeniedHandler implements AccessDeniedHandlerInterface
{
public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly Environment $twig)
{
}
public function handle(Request $request, AccessDeniedException $accessDeniedException): Response
{
$attributes = $accessDeniedException->getAttributes();
$pathInfo = $request->getPathInfo();
if (str_contains($pathInfo, "/admin")) {
return new RedirectResponse($this->urlGenerator->generate("app_home"));
}
if (in_array('application/json', $request->getAcceptableContentTypes())) {
return new JsonResponse(null, Response::HTTP_FORBIDDEN);
}
return new JsonResponse(null, Response::HTTP_FORBIDDEN);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
{
/**
* @var \Symfony\Component\Routing\Generator\UrlGeneratorInterface|mixed
*/
public $urlGenerator;
/**
* @var AccessDeniedHandler|mixed
*/
public $accessDeniedHandler;
public function __construct(
UrlGeneratorInterface $urlGenerator,
AccessDeniedHandler $accessDeniedHandler
) {
$this->urlGenerator = $urlGenerator;
$this->accessDeniedHandler = $accessDeniedHandler;
}
public function start(Request $request, AuthenticationException $authException = null): Response
{
$previous = $authException !== null ? $authException->getPrevious() : null;
// Parque le composant security est un peu bête et ne renvoie pas un AccessDenied pour les utilisateur connecté avec un cookie
// On redirige le traitement de cette situation vers le AccessDeniedHandler
if ($authException instanceof InsufficientAuthenticationException &&
$previous instanceof AccessDeniedException &&
$authException->getToken() instanceof RememberMeToken
) {
return $this->accessDeniedHandler->handle($request, $previous);
}
if (in_array('application/json', $request->getAcceptableContentTypes())) {
return new JsonResponse(
['title' => "Vous n'avez pas les permissions suffisantes pour effectuer cette action"],
Response::HTTP_FORBIDDEN
);
}
return new RedirectResponse($this->urlGenerator->generate('app_login'));
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Security;
use App\Entity\Account;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; // L'objet moderne pour les opérations de sécurité
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
use TargetPathTrait;
public const LOGIN_ROUTE = 'app_login';
// Les propriétés typées sont la norme en Symfony 7 / PHP 8.2+
private EntityManagerInterface $entityManager;
private UrlGeneratorInterface $urlGenerator;
private Security $security;
public function __construct(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, Security $security)
{
$this->entityManager = $entityManager;
$this->urlGenerator = $urlGenerator;
$this->security = $security;
}
public function supports(Request $request): bool
{
return ($request->attributes->get('_route') === self::LOGIN_ROUTE) && $request->isMethod('POST');
}
/**
* @param Request $request La requête HTTP entrante
* @return Passport Le passeport contenant l'utilisateur et les informations d'authentification
*/
public function authenticate(Request $request): Passport
{
$email = (string) $request->request->get('_username', '');
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email);
return new Passport(
// 1. UserBadge: Charge l'utilisateur par l'email
new UserBadge($email, function(string $userIdentifier): Account {
$user = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $userIdentifier]);
if (!$user) {
throw new CustomUserMessageAuthenticationException('Email ou mot de passe invalide.');
}
return $user;
}),
// 2. Credentials: Vérifie le mot de passe
new PasswordCredentials($request->request->get('_password', '')),
[
new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
]
);
}
/**
* @param Request $request La requête actuelle
* @param TokenInterface $token Le jeton d'authentification
* @param string $firewallName Le nom du pare-feu utilisé ('main' dans votre cas)
* @return Response|null
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse($this->urlGenerator->generate('app_home'));
}
/**
* @param Request $request
* @return string L'URL de la page de connexion
*/
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate(self::LOGIN_ROUTE);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Security;
class PasswordGenerator
{
private int $length;
private string $charsLower = 'abcdefghijklmnopqrstuvwxyz';
private string $charsUpper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
private string $digits = '0123456789';
private string $specials = '@#_-'; // Caractères spéciaux limités
public function __construct(int $length = 12)
{
if ($length < 4) {
throw new \InvalidArgumentException('La longueur du mot de passe doit être au moins 4.');
}
$this->length = $length;
}
public function generate(): string
{
// Garantir la présence d'au moins un caractère de chaque type
$password = [
$this->randomChar($this->charsLower),
$this->randomChar($this->charsUpper),
$this->randomChar($this->digits),
$this->randomChar($this->specials),
];
$allChars = $this->charsLower . $this->charsUpper . $this->digits . $this->specials;
for ($i = 4; $i < $this->length; $i++) {
$password[] = $this->randomChar($allChars);
}
shuffle($password);
return implode('', $password);
}
private function randomChar(string $chars): string
{
return $chars[random_int(0, strlen($chars) - 1)];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Security;
use App\Entity\Account;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class UserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user): void
{
if (!$user instanceof Account) {
return;
}
if (!$user->isActif()) {
throw new CustomUserMessageAccountStatusException('Votre compte a été désactivé.');
}
}
public function checkPostAuth(UserInterface $user): void
{
// Pas de vérifications post-authentification pour linstant
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Service\Generator;
/**
* Class TempPasswordGenerator
*
* Fournit des fonctionnalités pour générer des mots de passe temporaires sécurisés.
*/
class TempPasswordGenerator
{
private const DEFAULT_LENGTH = 12;
private const DEFAULT_CHARACTERS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-_=+[]{}|;:,.<>?';
/**
* Génère un mot de passe temporaire aléatoire.
*
* @param int $length Longueur désirée du mot de passe (par défaut 12).
* @param string $characters Jeu de caractères à utiliser dans le mot de passe.
* @return string Mot de passe généré.
*/
public static function generate(int $length = self::DEFAULT_LENGTH, string $characters = self::DEFAULT_CHARACTERS): string
{
if ($length <= 0) {
$length = self::DEFAULT_LENGTH;
}
$password = '';
$maxIndex = strlen($characters) - 1;
for ($i = 0; $i < $length; $i++) {
$password .= $characters[random_int(0, $maxIndex)];
}
return $password;
}
/**
* Vérifie si un mot de passe remplit certaines exigences de complexité.
*
* @param string $password Mot de passe à vérifier.
* @return bool True si les critères sont respectés, false sinon.
*/
public static function isComplex(string $password): bool
{
return strlen($password) >= 8
&& preg_match('/[A-Z]/', $password)
&& preg_match('/[a-z]/', $password)
&& preg_match('/[0-9]/', $password)
&& preg_match('/[!@#$%^&*()\-_=+\[\]{}|;:,.<>?]/', $password);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Service\Mailer\Event;
use App\Entity\Account;
class CreatedAdminEvent
{
public function __construct(
private readonly Account $account,
private readonly string $password
) {
}
public function getAccount(): Account
{
return $this->account;
}
public function getPassword(): string
{
return $this->password;
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Service\Mailer;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Component\HttpKernel\Profiler\Profiler;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Header\IdentificationHeader;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
class Mailer
{
private readonly MailerInterface $mailer;
public function __construct(
MailerInterface $mailer,
private readonly EntityManagerInterface $entityManager,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly ?Profiler $profiler,
private readonly Environment $environment,
) {
$this->mailer = $mailer;
}
private function convertMjmlToHtml(string $mjmlContent): string
{
$process = new Process(['mjml', '--stdin']);
$process->setInput($mjmlContent);
try {
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
return $process->getOutput();
} catch (ProcessFailedException|Exception) {
return ''; // Retourne vide en cas d'échec
}
}
/**
* @param string $address
* @param string $addressName
* @param string $subject
* @param string $template
* @param array<string, mixed> $data
* @param array<\Symfony\Component\Mime\Part\DataPart> $files
*/
public function send(
string $address,
string $addressName,
string $subject,
string $template,
array $data,
array $files = []
): void {
$dest = new Address($address, $addressName);
$src = new Address("contact@e-page.fr", "E-PAGE");
$mail = (new Email())
->subject($subject)
->to($dest)
->from($src);
$mjmlGenerator = $this->environment->render($template, [
'system' => [
'subject' => $subject,
'path' => $_ENV['PATH_URL'],
],
'datas' => $data,
]);
$htmlContent = $this->convertMjmlToHtml($mjmlGenerator);
$txtContent = $this->environment->render('txt-'.$template,[
'system' => [
'subject' => $subject,
'path' => $_ENV['PATH_URL'],
],
'datas' => $data,
]);
foreach ($files as $file) {
$mail->addPart($file);
}
$mail->html($htmlContent);
$mail->text($txtContent);
$this->mailer->send($mail);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Service\Mailer;
use App\Service\Mailer\Event\CreatedAdminEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsEventListener(event: CreatedAdminEvent::class, method: 'onAdminEvent')]
class MailerSubscriber
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly Mailer $mailer
) {
}
public function onAdminEvent(CreatedAdminEvent $createdAdminEvent): void
{
$account = $createdAdminEvent->getAccount();
$password = $createdAdminEvent->getPassword();
$this->mailer->send(
$account->getEmail(),
$account->getUsername(),
"[CRM] - Création d'un compte administrateur",
"mails/new_admin.twig",
[
'username' => $account->getUsername(),
'password' => $password,
'url' => $this->urlGenerator->generate('app_home'),
]
);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Service\ResetPassword\Event;
class ResetPasswordConfirmEvent
{
private $password;
public function __construct()
{
}
public function getPassword(): string
{
return $this->password;
}
/**
* @param mixed $password
*/
public function setPassword($password): void
{
$this->password = $password;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Service\ResetPassword\Event;
class ResetPasswordEvent
{
private ?string $email;
public function __construct()
{
}
public function getEmail(): string
{
return $this->email;
}
/**
* @param string|null $email
*/
public function setEmail(?string $email): void
{
$this->email = $email;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Service\ResetPassword;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Service\Generator\TempPasswordGenerator;
use App\Service\Mailer\Mailer;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsEventListener(event: ResetPasswordEvent::class, method: 'onResetPassword')]
class ResetPasswordSubscriber
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly EntityManagerInterface $entityManager,
private readonly Mailer $mailer,
private readonly RequestStack $requestStack,
) {
}
public function onResetPassword(ResetPasswordEvent $resetPasswordEvent): void
{
$email = $resetPasswordEvent->getEmail();
$account = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $email]);
if (!$account instanceof Account) {
return;
}
$existingRequest = $this->entityManager->getRepository(AccountResetPasswordRequest::class)
->findOneBy(['Account' => $account]);
$now = new \DateTimeImmutable();
$sendNewRequest = true;
$request = null;
if ($existingRequest instanceof AccountResetPasswordRequest) {
$expiredAt = $existingRequest->getExpiresAt();
if ($expiredAt < $now) {
$this->entityManager->remove($existingRequest);
} else {
$sendNewRequest = false;
$request = $existingRequest;
}
}
if ($sendNewRequest) {
$expiredAt = $now->modify('+1 hour');
$request = new AccountResetPasswordRequest();
$request->setAccount($account);
$request->setToken(TempPasswordGenerator::generate(50, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'));
$request->setRequestedAt($now);
$request->setExpiresAt($expiredAt);
$this->entityManager->persist($request);
$this->entityManager->flush();
}
$resetLink = $this->urlGenerator->generate(
'app_forgot_password_confirm',
['id' => $account->getId(), 'token' => $request->getToken()],
UrlGeneratorInterface::ABSOLUTE_URL
);
$this->mailer->send(
$account->getEmail(),
$account->getUsername(),
'[CRM] - Lien pour réinitialiser votre mot de passe',
'mails/reset.twig',
[
'account' => $account,
'request' => $request,
'resetLink' => $resetLink,
]
);
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Twig;
use Detection\MobileDetect;
use Psr\Cache\CacheItemPoolInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
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 readonly bool $isDev;
public function __construct(
private readonly string $manifest,
private readonly CacheItemPoolInterface $cache,
) {
// Respecte la logique existante : VITE_LOAD == "0" est considéré comme DEV.
$this->isDev = $_ENV['VITE_LOAD'] == "0";
}
public function getFunctions(): array
{
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()
{
$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) {
$item = $this->cache->getItem(self::CACHE_KEY);
if ($item->isHit()) {
$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
{
if ($this->isDev) {
return $this->assetDev($entry, $deps);
}
return $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;
}
public function assetProd(string $entry): string
{
$this->loadManifest();
$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;
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;
}
return $html;
}
// --- Nouvelle Gestion des Favicons ---
public function favicons(): string
{
if ($this->isDev) {
return $this->faviconsDev();
}
return $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
{
$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;
}
}
return $faviconHtml;
}
}

View File

@@ -35,6 +35,71 @@
"migrations/.gitignore"
]
},
"google/apiclient": {
"version": "2.18",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "2.10",
"ref": "07a97ec434d43b7903c78069a04c92adb6442e52"
},
"files": [
"config/packages/google_apiclient.yaml"
]
},
"knplabs/knp-paginator-bundle": {
"version": "v6.10.0"
},
"league/flysystem-bundle": {
"version": "3.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "913dc3d7a5a1af0d2b044c5ac3a16e2f851d7380"
},
"files": [
"config/packages/flysystem.yaml",
"var/storage/.gitignore"
]
},
"liip/imagine-bundle": {
"version": "2.15",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.8",
"ref": "d1227d002b70d1a1f941d91845fcd7ac7fbfc929"
},
"files": [
"config/packages/liip_imagine.yaml",
"config/routes/liip_imagine.yaml"
]
},
"nelmio/cors-bundle": {
"version": "2.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"config/packages/nelmio_cors.yaml"
]
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
},
"files": [
"phpstan.dist.neon"
]
},
"phpunit/phpunit": {
"version": "12.5",
"recipe": {
@@ -50,6 +115,30 @@
"bin/phpunit"
]
},
"presta/sitemap-bundle": {
"version": "v4.2.0"
},
"sentry/sentry-symfony": {
"version": "5.8",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "5.0",
"ref": "b6cb4b34429dadecd7187852123be19d628fa37a"
},
"files": [
"config/packages/sentry.yaml"
]
},
"symfony/amazon-mailer": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.4",
"ref": "9648db3ecae5c8a6b1a5f74715d3907124348815"
}
},
"symfony/asset-mapper": {
"version": "7.4",
"recipe": {
@@ -229,21 +318,6 @@
"config/routes/security.yaml"
]
},
"symfony/stimulus-bundle": {
"version": "2.31",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.24",
"ref": "3357f2fa6627b93658d8e13baa416b2a94a50c5f"
},
"files": [
"assets/controllers.json",
"assets/controllers/csrf_protection_controller.js",
"assets/controllers/hello_controller.js",
"assets/stimulus_bootstrap.js"
]
},
"symfony/translation": {
"version": "7.4",
"recipe": {
@@ -270,17 +344,14 @@
"templates/base.html.twig"
]
},
"symfony/ux-turbo": {
"version": "2.31",
"symfony/uid": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.20",
"ref": "287f7c6eb6e9b65e422d34c00795b360a787380b"
},
"files": [
"config/packages/ux_turbo.yaml"
]
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/validator": {
"version": "7.4",
@@ -321,5 +392,17 @@
},
"twig/extra-bundle": {
"version": "v3.22.1"
},
"vich/uploader-bundle": {
"version": "2.9",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.13",
"ref": "1b3064c2f6b255c2bc2f56461aaeb76b11e07e36"
},
"files": [
"config/packages/vich_uploader.yaml"
]
}
}

View File

@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

18
templates/base.twig Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="{{ app.request.locale }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Accueil{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<meta name="robots" content="noindex, nofollow">
{{ vite_asset('app.js', []) }}
</head>
{# Le corps aura un fond gris clair pour correspondre au fond du logo #}
<body class="flex flex-col min-h-screen bg-gray-100">
{% block body %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,141 @@
{% use 'form_div_layout.html.twig' %}
{# ---------- FORM START / END ---------- #}
{% block form_start -%}
{{ parent() }}
{%- endblock %}
{% block form_end -%}
{{ parent() }}
{%- endblock %}
{# ---------- ROW ---------- #}
{% block form_row %}
<div class="mb-5">
{{ form_label(form) }}
<div class="mt-1">
{{ form_widget(form) }}
</div>
{% if not compound and not form.vars.valid %}
{# Affiche l'erreur en bas du champ simple #}
<p class="text-sm text-red-600 mt-1">{{ form_errors(form) }}</p>
{% else %}
{# Affiche l'erreur pour les champs composés (si form_errors n'est pas déjà dans le widget) #}
{{ form_errors(form) }}
{% endif %}
</div>
{% endblock %}
{# ---------- LABEL ---------- #}
{% block form_label %}
{% if label is not same as(false) %}
<label for="{{ id }}" class="block text-sm font-medium text-gray-700">
{{ label|trans({}, translation_domain) }}
{% if required %}
<span class="text-red-500">*</span>
{% endif %}
</label>
{% endif %}
{% endblock %}
{# ---------- ERRORS ---------- #}
{% block form_errors %}
{% if errors|length > 0 %}
<ul class="mt-1 text-sm text-red-600 list-disc list-inside">
{% for error in errors %}
<li>{{ error.message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
{# ---------- WIDGET DISPATCH ---------- #}
{% block form_widget %}
{% if compound %}
{{ block('form_widget_compound') }}
{% else %}
{{ block('form_widget_simple') }}
{% endif %}
{% endblock %}
{# --- STYLE COMMUN POUR WIDGETS (Light Mode) --- #}
{# ---------- SIMPLE INPUTS (text, email, number...) ---------- #}
{% block form_widget_simple %}
{% set type = type|default('text') %}
<input
type="{{ type }}"
{{ block('widget_attributes') }}
value="{{ value }}"
class="form-input mt-1 block w-full px-3 py-2 bg-white border border-gray-300 text-gray-900 placeholder-gray-400 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition duration-150"
/>
{% endblock %}
{# ---------- TEXTAREA ---------- #}
{% block textarea_widget %}
<textarea
{{ block('widget_attributes') }}
class="form-textarea form-input mt-1 block w-full px-3 py-2 bg-white border border-gray-300 text-gray-900 placeholder-gray-400 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition duration-150"
>{{ value }}</textarea>
{% endblock %}
{# ---------- SELECT ---------- #}
{% block choice_widget_collapsed %}
<select
{{ block('widget_attributes') }}
class="form-select form-input mt-1 block w-full px-3 py-2 bg-white border border-gray-300 text-gray-900 placeholder-gray-400 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition duration-150"
>
{% if placeholder is not none %}
<option value="" {% if required and value is empty %}selected{% endif %}>
{{ placeholder != '' ? (placeholder|trans({}, translation_domain)) : '' }}
</option>
{% endif %}
{% for group_label, choice in choices %}
{% if choice is iterable %}
<optgroup label="{{ group_label|trans({}, translation_domain) }}">
{% for nested_choice in choice %}
<option value="{{ nested_choice.value }}" {% if nested_choice is selectedchoice(value) %}selected{% endif %}>
{{ nested_choice.label|trans({}, translation_domain) }}
</option>
{% endfor %}
</optgroup>
{% else %}
<option value="{{ choice.value }}" {% if choice is selectedchoice(value) %}selected{% endif %}>
{{ choice.label|trans({}, translation_domain) }}
</option>
{% endif %}
{% endfor %}
</select>
{% endblock %}
{# ---------- CHECKBOX ---------- #}
{% block checkbox_widget %}
<div class="flex items-center">
<input type="checkbox"
{{ block('widget_attributes') }}
{% if value not in ['', null] %} value="{{ value }}"{% endif %}
{% if checked %}checked="checked"{% endif %}
class="form-checkbox h-5 w-5 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
</div>
{% endblock %}
{# ---------- RADIO ---------- #}
{% block radio_widget %}
<input type="radio"
{{ block('widget_attributes') }}
value="{{ value }}"
{% if checked %}checked="checked"{% endif %}
class="form-radio h-5 w-5 text-indigo-600 border-gray-300 focus:ring-indigo-500">
{% endblock %}
{# ---------- FILE ---------- #}
{% block file_widget %}
<input type="file"
{{ block('widget_attributes') }}
class="block w-full text-sm text-gray-800 file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-indigo-600 file:text-white
hover:file:bg-indigo-700
bg-white border border-gray-300 rounded-md shadow-sm">
{% endblock %}

67
templates/home.twig Normal file
View File

@@ -0,0 +1,67 @@
{% extends 'base.twig' %}
{% block body %}
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-xl shadow-lg">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
{{ 'security.login'|trans }}
</h2>
{# Display error messages if login fails #}
{% if error %}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{{ error.messageKey|trans(error.messageData, 'security') }}
</div>
{% endif %}
{% for message in app.flashes('success') %}
<div class="p-4 text-sm text-green-700 bg-green-100 rounded-lg" role="alert">
{{ message }}
</div>
{% endfor %}
{# The actual login form #}
<form class="mt-8 space-y-6" action="{{ path('app_home') }}" method="post">
<input type="hidden" name="remember" value="true">
{# Username Field (Email) #}
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="username" class="sr-only">{{ 'label.email'|trans }}</label>
<input id="username" name="_username" type="email" autocomplete="email" required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="{{ 'label.email'|trans }}" value="{{ last_username }}" autofocus>
</div>
{# Password Field #}
<div>
<label for="password" class="sr-only">{{ 'label.password'|trans }}</label>
<input id="password" name="_password" type="password" autocomplete="current-password" required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="{{ 'label.password'|trans }}">
</div>
</div>
{# Remember Me & Forgot Password (Optional) #}
<div class="flex items-center justify-between">
<div class="text-sm">
<a href="{{ path('app_forgot_password') }}" class="font-medium text-indigo-600 hover:text-indigo-500">
{{ 'link.forgot_password'|trans }}
</a>
</div>
</div>
{# CSRF Token (Important for security) #}
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
{# Submit Button #}
<div>
<button type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ 'button.sign_in'|trans }}
</button>
</div>
</form>
</div>
</div>
{% endblock %}

28
templates/mails/base.twig Normal file
View File

@@ -0,0 +1,28 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-text>Bonjour, </mj-text>
{% if 'ROLE_CUSTOMER' in datas.account.roles %}
<mj-text>Nous avons reçu une demande de réinitialisation de mot de passe pour votre espace client.</mj-text>
{% else %}
<mj-text>Nous avons reçu une demande de réinitialisation de mot de passe pour votre compte E-Cosplay.</mj-text>
{% endif %}
<mj-text>Pour réinitialiser votre mot de passe, veuillez cliquer sur le bouton ci-dessous. Ce lien est valable pour une durée limitée.</mj-text>
<mj-button href="{{ datas.resetLink }}">
Réinitialiser mon mot de passe
</mj-button>
<mj-text padding-top="20px">
Ce lien expirera le {{ datas.request.expiresAt|date('d/m/Y à H:i') }}.
<br/>
Veuillez l'utiliser avant cette date et heure.
</mj-text>
<mj-text>Si vous n'avez pas demandé cette réinitialisation de mot de passe, veuillez ignorer cet e-mail. Votre mot de passe actuel restera inchangé.</mj-text>
<mj-text padding-top="20px">Cordialement,</mj-text>
<mj-text>L'équipe E-Cosplay</mj-text>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-text font-size="16px" line-height="24px">
Bonjour,
<br/><br/>
Nous avons le plaisir de vous informer que votre compte administrateur a été créé.
<br/><br/>
Voici vos identifiants de connexion temporaires :
<br/>
<strong>Nom d'utilisateur :</strong> `{{ datas.username }}`
<br/>
<strong>Mot de passe :</strong> `{{ datas.password }}`
<br/><br/>
Pour des raisons de sécurité, nous vous demandons de bien vouloir modifier votre mot de passe lors de votre première connexion.
<br/><br/>
Vous pouvez vous connecter à votre compte en cliquant sur le lien ci-dessous :
</mj-text>
<mj-button href="{{ system.path }}{{ datas.url }}" background-color="#4A90E2" color="#ffffff" font-size="16px" border-radius="5px">
Se connecter
</mj-button>
<mj-text font-size="16px" line-height="24px">
<br/><br/>
Si vous avez des questions ou rencontrez des difficultés, n'hésitez pas à nous contacter.
<br/><br/>
Cordialement,
<br/>
L'équipe CRM
</mj-text>
{% endblock %}

View File

@@ -0,0 +1,29 @@
reset.twig Thu Dec 11 17:18:45 2025
{% extends 'mails/base.twig' %}
{% block content %}
<mj-text>Bonjour, </mj-text>
{% if 'ROLE_CUSTOMER' in datas.account.roles %}
<mj-text>Nous avons reçu une demande de réinitialisation de mot de passe pour votre espace client.</mj-text>
{% else %}
<mj-text>Nous avons reçu une demande de réinitialisation de mot de passe pour votre compte E-Cosplay.</mj-text>
{% endif %}
<mj-text>Pour réinitialiser votre mot de passe, veuillez cliquer sur le bouton ci-dessous. Ce lien est valable pour une durée limitée.</mj-text>
<mj-button href="{{ datas.resetLink }}">
Réinitialiser mon mot de passe
</mj-button>
<mj-text padding-top="20px">
Ce lien expirera le {{ datas.request.expiresAt|date('d/m/Y à H:i') }}.
<br/>
Veuillez l'utiliser avant cette date et heure.
</mj-text>
<mj-text>Si vous n'avez pas demandé cette réinitialisation de mot de passe, veuillez ignorer cet e-mail. Votre mot de passe actuel restera inchangé.</mj-text>
<mj-text padding-top="20px">Cordialement,</mj-text>
<mj-text>L'équipe CRM</mj-text>
{% endblock %}

View File

@@ -0,0 +1,74 @@
{% extends 'base.twig' %}
{% block title %}{{ 'events.reset_password'|trans }}{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-xl shadow-lg">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
{{ 'events.reset_password'|trans }}
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
{{ 'text.enter_new_password'|trans }}
</p>
{# Affichage des messages flash (ex: token expiré ou invalide) #}
{% for flash_error in app.flashes('reset_password_error') %}
<div class="p-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{{ flash_error }}
</div>
{% endfor %}
{# Le formulaire Symfony #}
{{ form_start(form, {'attr': {'class': 'mt-8 space-y-6'}}) }}
<div class="rounded-md shadow-sm -space-y-px">
{# Champ Nouveau Mot de Passe (first) #}
{# On suppose que form.plainPassword est un RepeatedType avec un champ 'first' et 'second' #}
<div>
{{ form_label(form.password.first, 'label.new_password'|trans, {'label_attr': {'class': 'sr-only'}}) }}
{{ form_widget(form.password.first, {
'attr': {
'class': 'appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm',
'placeholder': 'label.new_password'|trans,
'autocomplete': 'new-password',
'required': 'required'
}
}) }}
{{ form_errors(form.password.first) }}
</div>
{# Champ Confirmation Mot de Passe (second) #}
<div>
{{ form_label(form.password.second, 'label.confirm_password'|trans, {'label_attr': {'class': 'sr-only'}}) }}
{{ form_widget(form.password.second, {
'attr': {
'class': 'appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm',
'placeholder': 'label.confirm_password'|trans,
'autocomplete': 'new-password',
'required': 'required'
}
}) }}
{{ form_errors(form.password.second) }}
</div>
</div>
{# Affichage des erreurs globales du formulaire #}
{{ form_errors(form) }}
{# Bouton Soumettre #}
<div>
<button type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ 'button.reset_password'|trans }}
</button>
</div>
{{ form_end(form) }}
</div>
</div>
{% endblock %

View File

@@ -0,0 +1,70 @@
{% extends 'base.twig' %}
{% block title %}{{ 'events.forgot_password'|trans }}{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-xl shadow-lg">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
{{ 'events.forgot_password'|trans }}
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
{{ 'text.enter_email_for_reset'|trans }}
</p>
{# Affichage des messages flash (succès ou erreur) #}
{% for flash_error in app.flashes('reset_password_error') %}
<div class="p-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{{ flash_error }}
</div>
{% endfor %}
{% for message in app.flashes('success') %}
<div class="p-4 text-sm text-green-700 bg-green-100 rounded-lg" role="alert">
{{ message }}
</div>
{% endfor %}
{# Le formulaire Symfony #}
{{ form_start(form, {'attr': {'class': 'mt-8 space-y-6'}}) }}
<div class="rounded-md shadow-sm -space-y-px">
{# Champ Email #}
<div>
{{ form_label(form.email, 'label.email'|trans, {'label_attr': {'class': 'sr-only'}}) }}
{{ form_widget(form.email, {
'attr': {
'class': 'appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm',
'placeholder': 'label.email'|trans,
'autocomplete': 'email',
'required': 'required'
}
}) }}
{# Affichage des erreurs de champ spécifiques #}
{{ form_errors(form.email) }}
</div>
</div>
{# Bouton Soumettre #}
<div>
<button type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ 'button.send_reset_link'|trans }}
</button>
</div>
{{ form_end(form) }}
<div class="text-center text-sm">
<a href="{{ path('app_home') }}" class="font-medium text-indigo-600 hover:text-indigo-500">
{{ 'link.back_to_login'|trans }}
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends 'base.twig' %}
{% block title %}{{ 'events.reset_email_sent'|trans }}{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-xl shadow-lg text-center">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
{{ 'events.reset_email_sent'|trans }}
</h2>
{# Message de Sécurité IMPORTANT #}
<div class="p-6 bg-blue-50 border border-blue-200 text-blue-700 rounded-lg">
<p class="font-medium mb-2">
{{ 'text.check_inbox_title'|trans }}
</p>
<p class="text-sm">
{{ 'text.check_inbox_description'|trans }}
</p>
</div>
<p class="mt-4 text-sm text-gray-500">
{{ 'text.spam_folder_tip'|trans }}
</p>
{# Lien de Retour à la Connexion #}
<div class="mt-8">
<a href="{{ path('app_login') }}"
class="font-medium text-indigo-600 hover:text-indigo-500">
{{ 'link.back_to_login'|trans }}
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,30 @@
login_link: Connexion
register_link: Inscription
breadcrumb.login: Connexion
label.email: Adresse e-mail
label.password: Mot de passe
label.remember_me: Se souvenir de moi
button.sign_in: Se connecter
link.forgot_password: Mot de passe oublié ?
error.login_failed: Échec de la connexion.
security.login: Connexion à votre compte
events.forgot_password: Mot de passe oublié
breadcrumb.forgot_password: Mot de passe oublié
text.enter_email_for_reset: Veuillez entrer votre adresse e-mail pour recevoir un lien de réinitialisation.
button.send_reset_link: Envoyer le lien de réinitialisation
link.back_to_login: Retour à la connexion
events.reset_email_sent: E-mail de réinitialisation envoyé
text.check_inbox_title: Vérifiez votre boîte de réception 📥
text.check_inbox_description: Un e-mail a été envoyé avec un lien pour réinitialiser votre mot de passe. Il se peut qu'il arrive dans quelques minutes.
text.spam_folder_tip: Si vous ne le voyez pas, vérifiez votre dossier de courriers indésirables (spam).
events.reset_password: Réinitialiser le mot de passe
breadcrumb.reset_password: Réinitialisation du mot de passe
label.new_password: Nouveau mot de passe
label.confirm_password: Confirmer le nouveau mot de passe
text.enter_new_password: Veuillez saisir et confirmer votre nouveau mot de passe.
button.reset_password: Réinitialiser le mot de passe
open_user_menu_sr: Ouvrir le menu utilisateur
logged_in_as: Connecté en tant que
logout_link: Déconnexion
page.login: Connexion
logged_admin: Administration

27
tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

38
umami-docker.yaml Normal file
View File

@@ -0,0 +1,38 @@
---
services:
datas_umami:
image: ghcr.io/umami-software/umami:latest
ports:
- "27503:3000"
environment:
DATABASE_URL: postgresql://umami:umami@datas_db:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: replace-me-with-a-random-string
TRACKER_SCRIPT_NAME: 'vs.js'
COLLECT_API_ENDPOINT: '/vs'
depends_on:
datas_db:
condition: service_healthy
init: true
restart: always
healthcheck:
test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
interval: 5s
timeout: 5s
retries: 5
datas_db:
image: postgres:15-alpine
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: umami
volumes:
- umami-db-data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
umami-db-data:

14
update.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
RED='\033[0;31m'
ORANGE='\033[0;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
RESET='\033[0m' # Reset color to default
echo "${CYAN}#######################${RESET}"
echo "${CYAN}# E-PAGE UPDATE START #${RESET}"
echo "${CYAN}#######################${RESET}"
ansible-playbook -i ansible/hosts.ini ansible/playbook.yml
echo "${CYAN}##############${RESET}"
echo "${CYAN}# END UPDATE #${RESET}"
echo "${CYAN}##############${RESET}"

96
vite.config.js Normal file
View File

@@ -0,0 +1,96 @@
// vite.config.js
import { defineConfig } from 'vite';
import { resolve } from 'path';
import JavaScriptObfuscator from 'rollup-plugin-javascript-obfuscator';
import tailwindcss from '@tailwindcss/vite'
import viteCompression from 'vite-plugin-compression';
export default defineConfig({
root: './assets',
base: '/assets/',
server: {
host: "0.0.0.0",
allowedHosts: ["esyweb.local"],
port: 5173,
open: false,
cors: {
origin: ['https://esyweb.local']
},
},
resolve: {
alias: {
'@': resolve(__dirname, 'assets'),
},
// Extensions de fichiers à résoudre automatiquement
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue', '.scss', '.css'],
},
build: {
outDir: resolve(__dirname, 'public/build'),
assetsDir: '',
emptyOutDir: true,
manifest: true,
sourcemap: false,
// Minification par défaut : esbuild est déjà très rapide et efficace
minify: 'esbuild',
cssMinify: 'esbuild',
// NOUVEAU : Stratégie de découpage du code pour améliorer le cache client
rollupOptions: {
input: {
app: resolve(__dirname, 'assets/app.js'),
admin: resolve(__dirname, 'assets/admin.js'),
}
},
},
// --- Plugins Vite ---
plugins: [
tailwindcss(),
// NOUVEAU: Ajoutez Preact/React/Vue si vous utilisez ces frameworks
// preact(),
// 1. Compression des Assets (Gzip et Brotli)
// Crée des fichiers .gz et .br à côté des assets originaux.
// Votre serveur web (Nginx/Apache) doit être configuré pour servir ces versions compressées.
viteCompression({
verbose: true,
disable: false,
threshold: 10240, // Compresse seulement les fichiers > 10KB
algorithm: 'gzip',
ext: '.gz',
}),
viteCompression({
verbose: true,
disable: false,
threshold: 10240,
algorithm: 'brotliCompress', // Brotli est plus efficace que Gzip
ext: '.br',
// Ne génère le brotli que si l'algorithme est disponible (par défaut)
deleteOriginFile: false // Important : gardez les fichiers originaux
}),
JavaScriptObfuscator({
// Options recommandées pour un bon équilibre entre sécurité et performance
compact: true,
sourceMap: false,
controlFlowFlattening: true, // Très efficace mais coûteux en performance à l'exécution
deadCodeInjection: false,
debugProtection: false,
identifierNamesGenerator: 'hexadecimal', // Rend le code illisible
log: false,
numbersToExpressions: true,
simplify: true,
splitStrings: true,
stringArray: true,
stringArrayThreshold: 0.75,
transformObjectKeys: true,
unicodeEscapeSequence: false,
}),
],
define: {},
});