{{ message }}
+{{ message }}
+diff --git a/.env.test b/.env.test index 64bd111..978776d 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,5 @@ # define your env variables for the test env here KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' +MEILISEARCH_URL=http://meilisearch:7700 +MEILISEARCH_API_KEY=test diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e9fe71f..3da1a30 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,10 +29,17 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 5 + meilisearch: + image: getmeili/meilisearch:latest + env: + MEILI_MASTER_KEY: test + MEILI_ENV: development env: DATABASE_URL: "postgresql://app:secret@database:5432/e_ticket?serverVersion=16&charset=utf8" MESSENGER_TRANSPORT_DSN: "redis://redis:6379/messages" MAILER_DSN: "null://null" + MEILISEARCH_URL: "http://meilisearch:7700" + MEILISEARCH_API_KEY: "test" APP_ENV: test steps: - name: Checkout @@ -126,10 +133,17 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 5 + meilisearch: + image: getmeili/meilisearch:latest + env: + MEILI_MASTER_KEY: test + MEILI_ENV: development env: DATABASE_URL: "postgresql://app:secret@database:5432/e_ticket?serverVersion=16&charset=utf8" MESSENGER_TRANSPORT_DSN: "redis://redis:6379/messages" MAILER_DSN: "null://null" + MEILISEARCH_URL: "http://meilisearch:7700" + MEILISEARCH_API_KEY: "test" APP_ENV: test steps: - name: Checkout diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index 4b7b4c6..9058c4e 100644 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -25,10 +25,17 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 5 + meilisearch: + image: getmeili/meilisearch:latest + env: + MEILI_MASTER_KEY: test + MEILI_ENV: development env: DATABASE_URL: "postgresql://app:secret@database:5432/e_ticket?serverVersion=16&charset=utf8" MESSENGER_TRANSPORT_DSN: "redis://redis:6379/messages" MAILER_DSN: "null://null" + MEILISEARCH_URL: "http://meilisearch:7700" + MEILISEARCH_API_KEY: "test" APP_ENV: test steps: - name: Checkout diff --git a/assets/admin.js b/assets/admin.js new file mode 100644 index 0000000..0662b1c --- /dev/null +++ b/assets/admin.js @@ -0,0 +1,11 @@ +import "./admin.scss" + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('[data-confirm]').forEach(form => { + form.addEventListener('submit', (e) => { + if (!confirm(form.dataset.confirm)) { + e.preventDefault() + } + }) + }) +}) diff --git a/assets/admin.scss b/assets/admin.scss new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/assets/admin.scss @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/composer.json b/composer.json index 444b74f..c196185 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "doctrine/orm": "^3.6", "dompdf/dompdf": "*", "endroid/qr-code-bundle": "*", + "knplabs/knp-paginator-bundle": "^6.10", "knpuniversity/oauth2-client-bundle": "^2.20", "league/flysystem-aws-s3-v3": "^3.32", "league/flysystem-bundle": "^3.6", diff --git a/composer.lock b/composer.lock index b614b8b..35b0944 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ecb55cf346fc28e16c4caec521a016e2", + "content-hash": "53da9b302a2b28356f861372e8679637", "packages": [ { "name": "aws/aws-crt-php", @@ -2387,6 +2387,170 @@ }, "time": "2025-11-30T20:12:26+00:00" }, + { + "name": "knplabs/knp-components", + "version": "v5.2.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/knp-components.git", + "reference": "eabf39263fff305c0024820c3736e5b03e7edf50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/knp-components/zipball/eabf39263fff305c0024820c3736e5b03e7edf50", + "reference": "eabf39263fff305c0024820c3736e5b03e7edf50", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/event-dispatcher-contracts": "^3.0" + }, + "conflict": { + "doctrine/dbal": "<3.8" + }, + "require-dev": { + "doctrine/dbal": "^3.8 || ^4.0", + "doctrine/mongodb-odm": "^2.5.5", + "doctrine/orm": "^2.13 || ^3.0", + "doctrine/phpcr-odm": "^1.8 || ^2.0", + "ext-pdo_sqlite": "*", + "jackalope/jackalope-doctrine-dbal": "^1.12 || ^2.0", + "phpunit/phpunit": "^10.5 || ^11.3", + "propel/propel1": "^1.7", + "ruflin/elastica": "^7.0", + "solarium/solarium": "^6.0", + "symfony/http-foundation": "^5.4.38 || ^6.4.4 || ^7.0", + "symfony/http-kernel": "^5.4.38 || ^6.4.4 || ^7.0", + "symfony/property-access": "^5.4.38 || ^6.4.4 || ^7.0" + }, + "suggest": { + "doctrine/common": "to allow usage pagination with Doctrine ArrayCollection", + "doctrine/mongodb-odm": "to allow usage pagination with Doctrine ODM MongoDB", + "doctrine/orm": "to allow usage pagination with Doctrine ORM", + "doctrine/phpcr-odm": "to allow usage pagination with Doctrine ODM PHPCR", + "propel/propel1": "to allow usage pagination with Propel ORM", + "ruflin/elastica": "to allow usage pagination with ElasticSearch Client", + "solarium/solarium": "to allow usage pagination with Solarium Client", + "symfony/http-foundation": "to retrieve arguments from Request", + "symfony/property-access": "to allow sorting arrays" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Component\\": "src/Knp/Component" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "https://knplabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/KnpLabs/knp-components/contributors" + } + ], + "description": "Knplabs component library", + "homepage": "https://github.com/KnpLabs/knp-components", + "keywords": [ + "components", + "knp", + "knplabs", + "pager", + "paginator" + ], + "support": { + "issues": "https://github.com/KnpLabs/knp-components/issues", + "source": "https://github.com/KnpLabs/knp-components/tree/v5.2.0" + }, + "time": "2025-03-20T07:35:37+00:00" + }, + { + "name": "knplabs/knp-paginator-bundle", + "version": "v6.10.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/KnpPaginatorBundle.git", + "reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41", + "reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41", + "shasum": "" + }, + "require": { + "knplabs/knp-components": "^4.4 || ^5.0", + "php": "^8.1", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^6.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^10.5 || ^11.5 || ^12.2", + "symfony/templating": "^6.4 || ^7.0 || ^8.0", + "symfony/translation": "^6.4 || ^7.0 || ^8.0", + "twig/twig": "^3.0" + }, + "suggest": { + "symfony/translation": "To use the templates", + "twig/twig": "To use the templates" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Bundle\\PaginatorBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "https://knplabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/KnpLabs/KnpPaginatorBundle/contributors" + } + ], + "description": "Paginator bundle for Symfony to automate pagination and simplify sorting and other features", + "homepage": "https://github.com/KnpLabs/KnpPaginatorBundle", + "keywords": [ + "bundle", + "knp", + "knplabs", + "pager", + "pagination", + "paginator", + "symfony" + ], + "support": { + "issues": "https://github.com/KnpLabs/KnpPaginatorBundle/issues", + "source": "https://github.com/KnpLabs/KnpPaginatorBundle/tree/v6.10.0" + }, + "time": "2025-11-29T09:14:09+00:00" + }, { "name": "knpuniversity/oauth2-client-bundle", "version": "v2.20.2", diff --git a/config/bundles.php b/config/bundles.php index e87a17a..e36e03b 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -17,4 +17,5 @@ return [ League\FlysystemBundle\FlysystemBundle::class => ['all' => true], Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true], KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], + Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], ]; diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b823e91..7ccb71d 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -27,6 +27,7 @@ security: target: app_home access_control: + - { path: ^/admin, roles: ROLE_ROOT } - { path: ^/mon-compte, roles: ROLE_USER } when@test: diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php new file mode 100644 index 0000000..757fd75 --- /dev/null +++ b/src/Controller/AdminController.php @@ -0,0 +1,294 @@ +render('admin/dashboard.html.twig'); + } + + #[Route('/sync-meilisearch', name: 'app_admin_sync_meilisearch', methods: ['POST'])] + public function syncMeilisearch(EntityManagerInterface $em, MeilisearchService $meilisearch): Response + { + $allUsers = $em->getRepository(User::class)->findAll(); + $buyers = array_filter($allUsers, fn (User $u) => $u->isVerified() && !\in_array('ROLE_ORGANIZER', $u->getRoles(), true) && !\in_array('ROLE_ROOT', $u->getRoles(), true)); + + $meilisearch->createIndexIfNotExists('buyers'); + + $documents = array_map(fn (User $u) => [ + 'id' => $u->getId(), + 'firstName' => $u->getFirstName(), + 'lastName' => $u->getLastName(), + 'email' => $u->getEmail(), + 'createdAt' => $u->getCreatedAt()->format('d/m/Y'), + ], array_values($buyers)); + + if ([] !== $documents) { + $meilisearch->addDocuments('buyers', $documents); + } + + $this->addFlash('success', sprintf('%d acheteur(s) synchronise(s) dans Meilisearch.', \count($documents))); + + return $this->redirectToRoute('app_admin_dashboard'); + } + + #[Route('/utilisateurs', name: 'app_admin_users')] + public function users(EntityManagerInterface $em): Response + { + $users = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']); + + return $this->render('admin/users.html.twig', [ + 'users' => $users, + ]); + } + + #[Route('/acheteurs', name: 'app_admin_buyers')] + public function buyers(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator, MeilisearchService $meilisearch): Response + { + $query = $request->query->getString('q', ''); + $searchResults = null; + + if ('' !== $query) { + try { + $searchResults = $meilisearch->search('buyers', $query, ['limit' => 50]); + } catch (\Throwable) { + $this->addFlash('error', 'Erreur de recherche Meilisearch.'); + } + } + + if (null !== $searchResults && isset($searchResults['hits'])) { + $hitIds = array_map(fn (array $hit) => $hit['id'], $searchResults['hits']); + $allUsers = $em->getRepository(User::class)->findBy(['id' => $hitIds]); + $buyers = $allUsers; + } else { + $allUsers = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']); + $buyers = array_values(array_filter($allUsers, fn (User $u) => !\in_array('ROLE_ORGANIZER', $u->getRoles(), true) && !\in_array('ROLE_ROOT', $u->getRoles(), true))); + } + + $pagination = $paginator->paginate( + $buyers, + $request->query->getInt('page', 1), + 10, + ); + + return $this->render('admin/buyers.html.twig', [ + 'buyers' => $pagination, + 'query' => $query, + ]); + } + + #[Route('/organisateurs', name: 'app_admin_organizers')] + public function organizers(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response + { + $allUsers = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']); + $organizers = array_values(array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true))); + + $tab = $request->query->getString('tab', 'pending'); + if ('approved' === $tab) { + $filtered = array_values(array_filter($organizers, fn (User $u) => $u->isApproved())); + } else { + $filtered = array_values(array_filter($organizers, fn (User $u) => !$u->isApproved())); + } + + $pagination = $paginator->paginate( + $filtered, + $request->query->getInt('page', 1), + 10, + ); + + return $this->render('admin/organizers.html.twig', [ + 'organizers' => $pagination, + 'tab' => $tab, + ]); + } + + #[Route('/acheteurs/creer', name: 'app_admin_create_buyer', methods: ['POST'])] + public function createBuyer( + Request $request, + EntityManagerInterface $em, + UserPasswordHasherInterface $passwordHasher, + ValidatorInterface $validator, + MailerService $mailerService, + ): Response { + $user = new User(); + $user->setFirstName(trim($request->request->getString('first_name'))); + $user->setLastName(trim($request->request->getString('last_name'))); + $user->setEmail(trim($request->request->getString('email'))); + + $user->setPassword($passwordHasher->hashPassword($user, bin2hex(random_bytes(16)))); + + $token = bin2hex(random_bytes(32)); + $user->setEmailVerificationToken($token); + + $errors = $validator->validate($user); + if (0 === count($errors)) { + $em->persist($user); + $em->flush(); + + $verificationUrl = $this->generateUrl('app_verify_email', [ + 'token' => $token, + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $mailerService->sendEmail( + to: $user->getEmail(), + subject: 'Verifiez votre adresse email - E-Ticket', + content: $this->renderView('email/verification.html.twig', [ + 'firstName' => $user->getFirstName(), + 'verificationUrl' => $verificationUrl, + ]), + withUnsubscribe: false, + ); + + $this->addFlash('success', sprintf('Acheteur %s %s cree.', $user->getFirstName(), $user->getLastName())); + + return $this->redirectToRoute('app_admin_buyers'); + } + + foreach ($errors as $error) { + $this->addFlash('error', $error->getMessage()); + } + + return $this->redirectToRoute('app_admin_buyers'); + } + + #[Route('/acheteur/{id}/renvoyer-verification', name: 'app_admin_resend_verification', methods: ['POST'])] + public function resendVerification(User $user, EntityManagerInterface $em, MailerService $mailerService): Response + { + $token = bin2hex(random_bytes(32)); + $user->setEmailVerificationToken($token); + $em->flush(); + + $verificationUrl = $this->generateUrl('app_verify_email', [ + 'token' => $token, + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $mailerService->sendEmail( + to: $user->getEmail(), + subject: 'Verifiez votre adresse email - E-Ticket', + content: $this->renderView('email/verification.html.twig', [ + 'firstName' => $user->getFirstName(), + 'verificationUrl' => $verificationUrl, + ]), + withUnsubscribe: false, + ); + + $this->addFlash('success', sprintf('Email de verification renvoye a %s.', $user->getEmail())); + + return $this->redirectToRoute('app_admin_buyers'); + } + + #[Route('/acheteur/{id}/forcer-verification', name: 'app_admin_force_verification', methods: ['POST'])] + public function forceVerification(User $user, EntityManagerInterface $em): Response + { + $user->setIsVerified(true); + $user->setEmailVerifiedAt(new \DateTimeImmutable()); + $user->setEmailVerificationToken(null); + $em->flush(); + + $this->addFlash('success', sprintf('Email de %s %s force comme verifie.', $user->getFirstName(), $user->getLastName())); + + return $this->redirectToRoute('app_admin_buyers'); + } + + #[Route('/acheteur/{id}/reset-password', name: 'app_admin_reset_password', methods: ['POST'])] + public function resetPassword(User $user, MailerService $mailerService): Response + { + $resetUrl = $this->generateUrl('app_forgot_password', [], UrlGeneratorInterface::ABSOLUTE_URL); + + $mailerService->sendEmail( + to: $user->getEmail(), + subject: 'Reinitialisation de votre mot de passe - E-Ticket', + content: $this->renderView('email/admin_reset_password.html.twig', [ + 'firstName' => $user->getFirstName(), + 'email' => $user->getEmail(), + 'resetUrl' => $resetUrl, + ]), + withUnsubscribe: false, + ); + + $this->addFlash('success', sprintf('Lien de reinitialisation envoye a %s.', $user->getEmail())); + + return $this->redirectToRoute('app_admin_buyers'); + } + + #[Route('/acheteur/{id}/supprimer', name: 'app_admin_delete_buyer', methods: ['POST'])] + public function deleteBuyer(User $user, EntityManagerInterface $em, MeilisearchService $meilisearch): Response + { + $name = sprintf('%s %s', $user->getFirstName(), $user->getLastName()); + $userId = $user->getId(); + + $em->remove($user); + $em->flush(); + + try { + $meilisearch->deleteDocument('buyers', $userId); + } catch (\Throwable) { + } + + $this->addFlash('success', sprintf('Compte de %s supprime.', $name)); + + return $this->redirectToRoute('app_admin_buyers'); + } + + #[Route('/organisateur/{id}/approuver', name: 'app_admin_approve_organizer', methods: ['POST'])] + public function approveOrganizer(User $user, EntityManagerInterface $em, MailerService $mailerService): Response + { + $user->setIsApproved(true); + $em->flush(); + + $loginUrl = $this->generateUrl('app_login', [], UrlGeneratorInterface::ABSOLUTE_URL); + + $mailerService->sendEmail( + to: $user->getEmail(), + subject: 'Votre compte organisateur a ete approuve - E-Ticket', + content: $this->renderView('email/organizer_approved.html.twig', [ + 'firstName' => $user->getFirstName(), + 'loginUrl' => $loginUrl, + ]), + withUnsubscribe: false, + ); + + $this->addFlash('success', sprintf('Organisateur %s %s approuve.', $user->getFirstName(), $user->getLastName())); + + return $this->redirectToRoute('app_admin_organizers'); + } + + #[Route('/organisateur/{id}/refuser', name: 'app_admin_reject_organizer', methods: ['POST'])] + public function rejectOrganizer(User $user, EntityManagerInterface $em, MailerService $mailerService): Response + { + $em->remove($user); + $em->flush(); + + $mailerService->sendEmail( + to: $user->getEmail(), + subject: 'Votre demande de compte organisateur a ete refusee - E-Ticket', + content: $this->renderView('email/organizer_rejected.html.twig', [ + 'firstName' => $user->getFirstName(), + ]), + withUnsubscribe: false, + ); + + $this->addFlash('success', sprintf('Demande de %s %s refusee.', $user->getFirstName(), $user->getLastName())); + + return $this->redirectToRoute('app_admin_organizers'); + } +} diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index 1e09ca7..0fa8caa 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Entity\User; use App\Service\MailerService; +use App\Service\MeilisearchService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -90,7 +91,7 @@ class RegistrationController extends AbstractController } #[Route('/verification-email/{token}', name: 'app_verify_email')] - public function verifyEmail(string $token, EntityManagerInterface $em, MailerService $mailerService): Response + public function verifyEmail(string $token, EntityManagerInterface $em, MailerService $mailerService, MeilisearchService $meilisearch): Response { $user = $em->getRepository(User::class)->findOneBy(['emailVerificationToken' => $token]); @@ -105,6 +106,17 @@ class RegistrationController extends AbstractController $user->setEmailVerificationToken(null); $em->flush(); + if (!\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) { + $meilisearch->createIndexIfNotExists('buyers'); + $meilisearch->addDocuments('buyers', [[ + 'id' => $user->getId(), + 'firstName' => $user->getFirstName(), + 'lastName' => $user->getLastName(), + 'email' => $user->getEmail(), + 'createdAt' => $user->getCreatedAt()->format('d/m/Y'), + ]]); + } + if (\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) { $mailerService->sendEmail( to: $user->getEmail(), diff --git a/src/MessageHandler/MeilisearchMessageHandler.php b/src/MessageHandler/MeilisearchMessageHandler.php index 584f929..c7aee37 100644 --- a/src/MessageHandler/MeilisearchMessageHandler.php +++ b/src/MessageHandler/MeilisearchMessageHandler.php @@ -17,13 +17,16 @@ class MeilisearchMessageHandler public function __invoke(MeilisearchMessage $message): void { match ($message->action) { - 'createIndex' => $this->meilisearch->createIndex($message->index, $message->payload['primaryKey'] ?? 'id'), - 'deleteIndex' => $this->meilisearch->deleteIndex($message->index), - 'addDocuments' => $this->meilisearch->addDocuments($message->index, $message->payload['documents']), - 'updateDocuments' => $this->meilisearch->updateDocuments($message->index, $message->payload['documents']), - 'deleteDocument' => $this->meilisearch->deleteDocument($message->index, $message->payload['documentId']), - 'deleteDocuments' => $this->meilisearch->deleteDocuments($message->index, $message->payload['ids']), - 'updateSettings' => $this->meilisearch->updateSettings($message->index, $message->payload['settings']), + 'createIndex' => $this->meilisearch->request('POST', '/indexes', [ + 'uid' => $message->index, + 'primaryKey' => $message->payload['primaryKey'] ?? 'id', + ]), + 'deleteIndex' => $this->meilisearch->request('DELETE', "/indexes/{$message->index}"), + 'addDocuments' => $this->meilisearch->request('POST', "/indexes/{$message->index}/documents", $message->payload['documents']), + 'updateDocuments' => $this->meilisearch->request('PUT', "/indexes/{$message->index}/documents", $message->payload['documents']), + 'deleteDocument' => $this->meilisearch->request('DELETE', "/indexes/{$message->index}/documents/{$message->payload['documentId']}"), + 'deleteDocuments' => $this->meilisearch->request('POST', "/indexes/{$message->index}/documents/delete-batch", $message->payload['ids']), + 'updateSettings' => $this->meilisearch->request('PATCH', "/indexes/{$message->index}/settings", $message->payload['settings']), default => throw new \InvalidArgumentException("Unknown action: {$message->action}"), }; } diff --git a/symfony.lock b/symfony.lock index 70d8dc3..f6fd310 100644 --- a/symfony.lock +++ b/symfony.lock @@ -50,6 +50,9 @@ ".php-cs-fixer.dist.php" ] }, + "knplabs/knp-paginator-bundle": { + "version": "v6.10.0" + }, "knpuniversity/oauth2-client-bundle": { "version": "2.20", "recipe": { diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig new file mode 100644 index 0000000..9ff7adc --- /dev/null +++ b/templates/admin/base.html.twig @@ -0,0 +1,47 @@ + + +
+ + +{{ message }}
+{{ message }}
+{{ buyers.getTotalItemCount }} acheteur{{ buyers.getTotalItemCount > 1 ? 's' : '' }} enregistre{{ buyers.getTotalItemCount > 1 ? 's' : '' }}.
+| Nom | +Inscription | +Email verifie | +Actions | +|
|---|---|---|---|---|
| {{ buyer.firstName }} {{ buyer.lastName }} | +{{ buyer.email }} | +{{ buyer.createdAt|date('d/m/Y') }} | ++ {% if buyer.verified %} + Oui + {% else %} + Non + {% endif %} + | +
+
+ {% if not buyer.verified %}
+
+
+ {% else %}
+
+ {% endif %}
+
+
+ |
+
| Aucun acheteur enregistre. | +||||
Le mot de passe sera genere aleatoirement.
+Bonjour {{ app.user.firstName }}, bienvenue sur l'administration.
+CA HT Global
+0,00 €
+CA HT Commission
+0,00 €
+Synchronise manuellement les acheteurs verifies dans l'index Meilisearch.
+ +{{ organizers.getTotalItemCount }} organisateur{{ organizers.getTotalItemCount > 1 ? 's' : '' }}.
+| Organisateur | +Raison sociale | +SIRET | +Ville | +Offre | +Actions | +
|---|---|---|---|---|---|
|
+ {{ orga.firstName }} {{ orga.lastName }} +{{ orga.email }} + |
+ {{ orga.companyName }} | +{{ orga.siret }} | +{{ orga.postalCode }} {{ orga.city }} | ++ {% if orga.offer %} + {{ orga.offer }} + {% else %} + — + {% endif %} + | +
+
+ {% if not orga.approved %}
+
+
+ {% else %}
+
+ {% endif %}
+
+ |
+
| + {% if tab == 'pending' %} + Aucune demande en attente. + {% else %} + Aucun organisateur valide. + {% endif %} + | +|||||
{{ users|length }} utilisateur{{ users|length > 1 ? 's' : '' }} enregistre{{ users|length > 1 ? 's' : '' }}.
+| Nom | +Roles | +Verifie | +Approuve | +Inscription | +|
|---|---|---|---|---|---|
| {{ user.firstName }} {{ user.lastName }} | +{{ user.email }} | ++ {% for role in user.roles %} + {{ role }} + {% endfor %} + | ++ {% if user.verified %}✓{% else %}✕{% endif %} + | ++ {% if user.approved %}✓{% else %}✕{% endif %} + | +{{ user.createdAt|date('d/m/Y') }} | +
© {{ "now"|date("Y") }} E-COSPLAY.
+© {{ "now"|date("Y") }} E-TICKET.
+Solution proposee par l'association e-cosplay.fr
RNA N°W022006988
Un administrateur E-Ticket a initie une reinitialisation de votre mot de passe pour le compte associe a l'adresse {{ email }}.
+Pour definir un nouveau mot de passe, cliquez sur le bouton ci-dessous :
++ Reinitialiser mon mot de passe +
+Si vous n'avez pas fait cette demande, vous pouvez ignorer cet email. Votre mot de passe actuel restera inchange.
+{% endblock %} diff --git a/templates/email/base.html.twig b/templates/email/base.html.twig index 1960d51..bd9ccff 100644 --- a/templates/email/base.html.twig +++ b/templates/email/base.html.twig @@ -10,75 +10,74 @@ margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - background-color: #f4f4f5; - color: #18181b; + background-color: #fbfbfb; + color: #111827; } .wrapper { width: 100%; padding: 40px 0; - background-color: #f4f4f5; + background-color: #fbfbfb; } .container { max-width: 600px; margin: 0 auto; background-color: #ffffff; - border-radius: 12px; - overflow: hidden; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + border: 4px solid #111827; + box-shadow: 8px 8px 0 rgba(0,0,0,1); } .header { - background-color: #7c3aed; - color: #ffffff; - padding: 32px; + background-color: #111827; + padding: 24px 32px; text-align: center; } - .header h1 { - margin: 0; - font-size: 24px; - font-weight: 700; + .header img { + height: 40px; + width: auto; } .content { padding: 32px; } .content h2 { margin: 0 0 16px; - font-size: 20px; - color: #18181b; + font-size: 22px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: -0.025em; + color: #111827; } .content p { margin: 0 0 16px; - font-size: 16px; + font-size: 15px; line-height: 1.6; - color: #3f3f46; + color: #374151; } .btn { display: inline-block; padding: 14px 28px; - background-color: #7c3aed; - color: #ffffff; + background-color: #fabf04; + color: #111827; text-decoration: none; - border-radius: 8px; - font-weight: 600; - font-size: 16px; - } - .qr-code { - text-align: center; - padding: 24px 0; - } - .qr-code img { - width: 200px; - height: 200px; + font-weight: 900; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.05em; + border: 3px solid #111827; + box-shadow: 4px 4px 0 rgba(0,0,0,1); } .footer { padding: 24px 32px; - text-align: center; - font-size: 13px; - color: #a1a1aa; - border-top: 1px solid #e4e4e7; + background-color: #fabf04; + border-top: 4px solid #111827; + } + .footer p { + margin: 0 0 4px; + font-size: 12px; + font-weight: 700; + color: #111827; } .footer a { - color: #7c3aed; - text-decoration: none; + color: #111827; + text-decoration: underline; } @@ -86,15 +85,17 @@
Votre demande de compte organisateur a ete approuvee par l'equipe E-Ticket.
+Vous pouvez desormais vous connecter et commencer a creer vos evenements.
++ Se connecter +
+{% endblock %} diff --git a/templates/email/organizer_rejected.html.twig b/templates/email/organizer_rejected.html.twig new file mode 100644 index 0000000..aef3188 --- /dev/null +++ b/templates/email/organizer_rejected.html.twig @@ -0,0 +1,12 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Votre demande de compte organisateur a ete refusee{% endblock %} + +{% block content %} +Nous avons examine votre demande de compte organisateur et malheureusement, celle-ci a ete refusee.
+Si vous pensez qu'il s'agit d'une erreur ou si vous souhaitez obtenir plus d'informations, n'hesitez pas a nous contacter.
++ Nous contacter +
+{% endblock %} diff --git a/tests/Controller/AdminControllerTest.php b/tests/Controller/AdminControllerTest.php new file mode 100644 index 0000000..f7ee251 --- /dev/null +++ b/tests/Controller/AdminControllerTest.php @@ -0,0 +1,350 @@ +request('GET', '/admin'); + + self::assertResponseRedirects(); + } + + public function testDashboardDeniedForNonRoot(): void + { + $client = static::createClient(); + $user = $this->createUser(); + + $client->loginUser($user); + $client->request('GET', '/admin'); + + self::assertResponseStatusCodeSame(403); + } + + public function testDashboardReturnsSuccessForRoot(): void + { + $client = static::createClient(); + $user = $this->createUser(['ROLE_ROOT']); + + $client->loginUser($user); + $client->request('GET', '/admin'); + + self::assertResponseIsSuccessful(); + } + + public function testUsersPageReturnsSuccessForRoot(): void + { + $client = static::createClient(); + $user = $this->createUser(['ROLE_ROOT']); + + $client->loginUser($user); + $client->request('GET', '/admin/utilisateurs'); + + self::assertResponseIsSuccessful(); + } + + public function testUsersPageDeniedForNonRoot(): void + { + $client = static::createClient(); + $user = $this->createUser(); + + $client->loginUser($user); + $client->request('GET', '/admin/utilisateurs'); + + self::assertResponseStatusCodeSame(403); + } + + public function testBuyersPageReturnsSuccessForRoot(): void + { + $client = static::createClient(); + $user = $this->createUser(['ROLE_ROOT']); + + $client->loginUser($user); + $client->request('GET', '/admin/acheteurs'); + + self::assertResponseIsSuccessful(); + } + + public function testBuyersSearchWithQuery(): void + { + $client = static::createClient(); + $admin = $this->createUser(['ROLE_ROOT']); + + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->expects(self::once())->method('search')->willReturn([ + 'hits' => [], + 'estimatedTotalHits' => 0, + ]); + static::getContainer()->set(MeilisearchService::class, $meilisearch); + + $client->loginUser($admin); + $client->request('GET', '/admin/acheteurs?q=test'); + + self::assertResponseIsSuccessful(); + } + + public function testSyncMeilisearch(): void + { + $client = static::createClient(); + $admin = $this->createUser(['ROLE_ROOT']); + + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->expects(self::once())->method('createIndexIfNotExists'); + static::getContainer()->set(MeilisearchService::class, $meilisearch); + + $client->loginUser($admin); + $client->request('POST', '/admin/sync-meilisearch'); + + self::assertResponseRedirects('/admin'); + } + + public function testCreateBuyerWithValidData(): void + { + $client = static::createClient(); + $admin = $this->createUser(['ROLE_ROOT']); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects(self::once())->method('sendEmail'); + static::getContainer()->set(MailerService::class, $mailer); + + $client->loginUser($admin); + $client->request('POST', '/admin/acheteurs/creer', [ + 'first_name' => 'Nouveau', + 'last_name' => 'Acheteur', + 'email' => 'new-buyer-'.uniqid().'@example.com', + ]); + + self::assertResponseRedirects('/admin/acheteurs'); + } + + public function testCreateBuyerWithDuplicateEmail(): void + { + $client = static::createClient(); + $admin = $this->createUser(['ROLE_ROOT']); + + $client->loginUser($admin); + $client->request('POST', '/admin/acheteurs/creer', [ + 'first_name' => 'Dup', + 'last_name' => 'Test', + 'email' => $admin->getEmail(), + ]); + + self::assertResponseRedirects('/admin/acheteurs'); + } + + public function testResendVerificationEmail(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $admin = $this->createUser(['ROLE_ROOT']); + + $buyer = new User(); + $buyer->setEmail('test-buyer-'.uniqid().'@example.com'); + $buyer->setFirstName('Buyer'); + $buyer->setLastName('Test'); + $buyer->setPassword('$2y$13$hashed'); + $em->persist($buyer); + $em->flush(); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects(self::once())->method('sendEmail'); + static::getContainer()->set(MailerService::class, $mailer); + + $client->loginUser($admin); + $client->request('POST', '/admin/acheteur/'.$buyer->getId().'/renvoyer-verification'); + + self::assertResponseRedirects('/admin/acheteurs'); + + $em->refresh($buyer); + self::assertNotNull($buyer->getEmailVerificationToken()); + } + + public function testResetPasswordSendsEmail(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $admin = $this->createUser(['ROLE_ROOT']); + + $buyer = new User(); + $buyer->setEmail('test-reset-admin-'.uniqid().'@example.com'); + $buyer->setFirstName('Reset'); + $buyer->setLastName('Test'); + $buyer->setPassword('$2y$13$hashed'); + $buyer->setIsVerified(true); + $em->persist($buyer); + $em->flush(); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects(self::once())->method('sendEmail'); + static::getContainer()->set(MailerService::class, $mailer); + + $client->loginUser($admin); + $client->request('POST', '/admin/acheteur/'.$buyer->getId().'/reset-password'); + + self::assertResponseRedirects('/admin/acheteurs'); + } + + public function testDeleteBuyer(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $admin = $this->createUser(['ROLE_ROOT']); + + $buyer = new User(); + $buyer->setEmail('test-delete-'.uniqid().'@example.com'); + $buyer->setFirstName('Delete'); + $buyer->setLastName('Test'); + $buyer->setPassword('$2y$13$hashed'); + $em->persist($buyer); + $em->flush(); + $buyerId = $buyer->getId(); + + $client->loginUser($admin); + $client->request('POST', '/admin/acheteur/'.$buyerId.'/supprimer'); + + self::assertResponseRedirects('/admin/acheteurs'); + + $deleted = $em->getRepository(User::class)->find($buyerId); + self::assertNull($deleted); + } + + public function testForceVerification(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $admin = $this->createUser(['ROLE_ROOT']); + + $buyer = new User(); + $buyer->setEmail('test-force-'.uniqid().'@example.com'); + $buyer->setFirstName('Force'); + $buyer->setLastName('Test'); + $buyer->setPassword('$2y$13$hashed'); + $em->persist($buyer); + $em->flush(); + + $client->loginUser($admin); + $client->request('POST', '/admin/acheteur/'.$buyer->getId().'/forcer-verification'); + + self::assertResponseRedirects('/admin/acheteurs'); + + $em->refresh($buyer); + self::assertTrue($buyer->isVerified()); + self::assertNotNull($buyer->getEmailVerifiedAt()); + self::assertNull($buyer->getEmailVerificationToken()); + } + + public function testOrganizersPagePendingTab(): void + { + $client = static::createClient(); + $admin = $this->createUser(['ROLE_ROOT']); + + $client->loginUser($admin); + $client->request('GET', '/admin/organisateurs'); + + self::assertResponseIsSuccessful(); + } + + public function testOrganizersPageApprovedTab(): void + { + $client = static::createClient(); + $admin = $this->createUser(['ROLE_ROOT']); + + $client->loginUser($admin); + $client->request('GET', '/admin/organisateurs?tab=approved'); + + self::assertResponseIsSuccessful(); + } + + public function testApproveOrganizer(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $admin = $this->createUser(['ROLE_ROOT']); + $orga = $this->createOrganizer($em); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects(self::once())->method('sendEmail'); + static::getContainer()->set(MailerService::class, $mailer); + + $client->loginUser($admin); + $client->request('POST', '/admin/organisateur/'.$orga->getId().'/approuver'); + + self::assertResponseRedirects('/admin/organisateurs'); + + $em->refresh($orga); + self::assertTrue($orga->isApproved()); + } + + public function testRejectOrganizer(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $admin = $this->createUser(['ROLE_ROOT']); + $orga = $this->createOrganizer($em); + $orgaId = $orga->getId(); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects(self::once())->method('sendEmail'); + static::getContainer()->set(MailerService::class, $mailer); + + $client->loginUser($admin); + $client->request('POST', '/admin/organisateur/'.$orgaId.'/refuser'); + + self::assertResponseRedirects('/admin/organisateurs'); + + $deleted = $em->getRepository(User::class)->find($orgaId); + self::assertNull($deleted); + } + + /** + * @param list