Add admin panel, Meilisearch buyer search, email redesign, and multiple features

Admin panel (/admin, ROLE_ROOT):
- Dashboard with CA HT Global/Commission cards and Meilisearch sync button
- Buyers page with search (Meilisearch), create form, pagination (KnpPaginator)
- Buyer actions: resend verification, force verify, reset password, delete
- Organizers page with tabs (pending/approved), approve/reject with emails
- Neo-brutalist design matching main site theme
- Vite admin entry point with dedicated SCSS
- CSP-compatible confirm dialogs via data-confirm attributes

Meilisearch integration:
- Auto-index buyers on email verification
- Remove from index on buyer deletion
- Manual sync button on dashboard
- Search bar on buyers page
- Add Meilisearch service to CI/SonarQube workflows
- Add MEILISEARCH env vars to .env.test
- Fix MeilisearchMessageHandler infinite loop: use request() directly instead
  of service methods that re-dispatch messages

Email templates:
- Redesign base email template to neo-brutalist style (borders, shadows, yellow footer)
- Add E-Cosplay logo, "E-Ticket solution proposee par e-cosplay.fr"
- Add admin_reset_password, organizer_approved, organizer_rejected templates

Other:
- Install knplabs/knp-paginator-bundle
- Add ^/admin access_control for ROLE_ROOT in security.yaml
- Update site footer with E-Ticket branding
- 18 admin tests, updated MeilisearchMessageHandler tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-19 14:07:07 +01:00
parent 0350b6e876
commit df7680d938
27 changed files with 1346 additions and 63 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

11
assets/admin.js Normal file
View File

@@ -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()
}
})
})
})

1
assets/admin.scss Normal file
View File

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

View File

@@ -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",

166
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "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",

View File

@@ -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],
];

View File

@@ -27,6 +27,7 @@ security:
target: app_home
access_control:
- { path: ^/admin, roles: ROLE_ROOT }
- { path: ^/mon-compte, roles: ROLE_USER }
when@test:

View File

@@ -0,0 +1,294 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/admin')]
#[IsGranted('ROLE_ROOT')]
class AdminController extends AbstractController
{
#[Route('', name: 'app_admin_dashboard')]
public function dashboard(): Response
{
return $this->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');
}
}

View File

@@ -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(),

View File

@@ -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}"),
};
}

View File

@@ -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": {

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin{% endblock %} - E-Ticket</title>
{% block javascripts %}
{{ vite_asset('admin.js') }}
{% endblock %}
</head>
<body style="background:#fbfbfb;color:#111827;" class="min-h-screen flex flex-col">
<header style="border-bottom:4px solid #111827;" class="bg-white sticky top-0 z-50">
<div class="flex items-center justify-between h-16 px-6">
<div class="flex items-center gap-6">
<a href="{{ path('app_admin_dashboard') }}" class="flex items-center gap-3">
<div style="border:2px solid #111827;box-shadow:3px 3px 0 rgba(0,0,0,1);background:#fabf04;" class="px-2 py-1 text-xs font-black uppercase">Admin</div>
<span class="text-xl font-black uppercase tracking-tighter italic">E-Ticket</span>
</a>
<nav class="flex items-center gap-1 ml-6">
{% set current_route = app.request.attributes.get('_route') %}
<a href="{{ path('app_admin_dashboard') }}" style="{{ current_route == 'app_admin_dashboard' ? 'background:#fabf04;border:2px solid #111827;box-shadow:2px 2px 0 rgba(0,0,0,1);' : '' }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest {{ current_route == 'app_admin_dashboard' ? '' : 'hover:bg-gray-100' }} transition-all">Dashboard</a>
<a href="{{ path('app_admin_buyers') }}" style="{{ current_route starts with 'app_admin_buyer' ? 'background:#fabf04;border:2px solid #111827;box-shadow:2px 2px 0 rgba(0,0,0,1);' : '' }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest {{ current_route starts with 'app_admin_buyer' ? '' : 'hover:bg-gray-100' }} transition-all">Acheteurs</a>
<a href="{{ path('app_admin_organizers') }}" style="{{ current_route starts with 'app_admin_organizer' ? 'background:#fabf04;border:2px solid #111827;box-shadow:2px 2px 0 rgba(0,0,0,1);' : '' }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest {{ current_route starts with 'app_admin_organizer' ? '' : 'hover:bg-gray-100' }} transition-all">Organisateurs</a>
</nav>
</div>
<div class="flex items-center gap-3">
<a href="{{ path('app_home') }}" style="border:2px solid #111827;" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest bg-white hover:bg-gray-100 transition-all">Retour au site</a>
<a href="{{ path('app_logout') }}" style="border:2px solid #111827;background:#111827;" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest text-white hover:bg-red-600 transition-all">Deconnexion</a>
</div>
</div>
</header>
<main class="flex-1" style="max-width:70rem;margin:0 auto;padding:2rem 1rem;width:100%;">
{% for message in app.flashes('success') %}
<div style="border:4px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#d1fae5;padding:1rem 1.5rem;margin-bottom:1.5rem;">
<p class="font-black text-sm">{{ message }}</p>
</div>
{% endfor %}
{% for message in app.flashes('error') %}
<div style="border:4px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fee2e2;padding:1rem 1.5rem;margin-bottom:1.5rem;">
<p class="font-black text-sm">{{ message }}</p>
</div>
{% endfor %}
{% block body %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,111 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Acheteurs{% endblock %}
{% block body %}
<div style="margin-bottom:2rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Acheteurs</h1>
<p class="font-bold text-gray-500 italic">{{ buyers.getTotalItemCount }} acheteur{{ buyers.getTotalItemCount > 1 ? 's' : '' }} enregistre{{ buyers.getTotalItemCount > 1 ? 's' : '' }}.</p>
</div>
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:1rem;">Rechercher</h2>
<form method="get" action="{{ path('app_admin_buyers') }}" style="display:flex;gap:1rem;align-items:flex-end;">
<div style="flex:1;">
<input type="text" name="q" value="{{ query }}"
style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;"
placeholder="Rechercher par nom, prenom ou email...">
</div>
<button type="submit" style="padding:0.5rem 1rem;border:2px solid #111827;background:#fabf04;cursor:pointer;white-space:nowrap;" class="font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Rechercher</button>
{% if query %}
<a href="{{ path('app_admin_buyers') }}" style="padding:0.5rem 1rem;border:2px solid #111827;background:white;white-space:nowrap;" class="font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">Effacer</a>
{% endif %}
</form>
</div>
{% if not query %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:1rem;">Creer un acheteur</h2>
<form method="post" action="{{ path('app_admin_create_buyer') }}" style="display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-end;">
<div style="flex:1;min-width:140px;">
<label style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Nom</label>
<input type="text" name="last_name" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="Dupont">
</div>
<div style="flex:1;min-width:140px;">
<label style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Prenom</label>
<input type="text" name="first_name" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="Jean">
</div>
<div style="flex:2;min-width:200px;">
<label style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.25rem;" class="font-black uppercase text-gray-400">Email</label>
<input type="email" name="email" required style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;" placeholder="jean.dupont@exemple.fr">
</div>
<button type="submit" style="padding:0.5rem 1rem;border:2px solid #111827;background:#fabf04;cursor:pointer;white-space:nowrap;" class="font-black uppercase text-xs tracking-widest hover:bg-green-500 hover:text-black transition-all">Creer</button>
</form>
</div>
{% endif %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#111827;">
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-white">Nom</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-white">Email</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-white">Inscription</th>
<th style="padding:0.75rem 1.5rem;text-align:center;" class="text-[10px] font-black uppercase tracking-widest text-white">Email verifie</th>
<th style="padding:0.75rem 1.5rem;text-align:right;" class="text-[10px] font-black uppercase tracking-widest text-white">Actions</th>
</tr>
</thead>
<tbody>
{% for buyer in buyers %}
<tr style="border-bottom:1px solid #e5e7eb;" class="hover:bg-gray-50 transition-all">
<td style="padding:0.75rem 1.5rem;" class="font-bold text-sm">{{ buyer.firstName }} {{ buyer.lastName }}</td>
<td style="padding:0.75rem 1.5rem;" class="text-sm text-gray-600">{{ buyer.email }}</td>
<td style="padding:0.75rem 1.5rem;" class="text-sm text-gray-400">{{ buyer.createdAt|date('d/m/Y') }}</td>
<td style="padding:0.75rem 1.5rem;text-align:center;">
{% if buyer.verified %}
<span style="background:#d1fae5;border:2px solid #111827;padding:0.15rem 0.5rem;" class="text-xs font-black uppercase">Oui</span>
{% else %}
<span style="background:#fee2e2;border:2px solid #111827;padding:0.15rem 0.5rem;" class="text-xs font-black uppercase">Non</span>
{% endif %}
</td>
<td style="padding:0.75rem 1.5rem;text-align:right;">
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
{% if not buyer.verified %}
<form method="post" action="{{ path('app_admin_resend_verification', {id: buyer.id}) }}">
<button type="submit" style="border:2px solid #111827;padding:0.4rem 0.75rem;background:white;cursor:pointer;" class="text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Renvoyer</button>
</form>
<form method="post" action="{{ path('app_admin_force_verification', {id: buyer.id}) }}">
<button type="submit" style="border:2px solid #111827;padding:0.4rem 0.75rem;background:#fabf04;cursor:pointer;" class="text-xs font-black uppercase tracking-widest hover:bg-green-500 hover:text-black transition-all">Forcer</button>
</form>
{% else %}
<form method="post" action="{{ path('app_admin_reset_password', {id: buyer.id}) }}">
<button type="submit" style="border:2px solid #111827;padding:0.4rem 0.75rem;background:white;cursor:pointer;" class="text-xs font-black uppercase tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Reset MDP</button>
</form>
{% endif %}
<form method="post" action="{{ path('app_admin_delete_buyer', {id: buyer.id}) }}" data-confirm="Etes-vous sur de vouloir supprimer le compte de {{ buyer.firstName }} {{ buyer.lastName }} ({{ buyer.email }}) ? Cette action est irreversible.">
<button type="submit" style="border:2px solid #991b1b;padding:0.4rem 0.75rem;background:#dc2626;color:white;cursor:pointer;" class="text-xs font-black uppercase tracking-widest hover:bg-red-800 transition-all">Supprimer</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="padding:3rem;text-align:center;" class="text-gray-400 font-bold text-sm">Aucun acheteur enregistre.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if buyers.getTotalItemCount > 10 %}
<div style="display:flex;justify-content:center;gap:0.5rem;margin-top:1.5rem;">
{% for page in 1..buyers.getPageCount %}
{% if page == buyers.getCurrentPageNumber %}
<span style="border:2px solid #111827;padding:0.4rem 0.75rem;background:#fabf04;" class="text-xs font-black">{{ page }}</span>
{% else %}
<a href="{{ path('app_admin_buyers', {page: page}) }}" style="border:2px solid #111827;padding:0.4rem 0.75rem;background:white;" class="text-xs font-black hover:bg-gray-100 transition-all">{{ page }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Creer un acheteur{% endblock %}
{% block body %}
<div style="margin-bottom:2rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Creer un acheteur</h1>
<p class="font-bold text-gray-500 italic">Le mot de passe sera genere aleatoirement.</p>
</div>
<div style="max-width:36rem;">
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;">
<form method="post" action="{{ path('app_admin_create_buyer') }}" style="display:flex;flex-direction:column;gap:1.5rem;">
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;">
<div style="flex:1;min-width:150px;">
<label for="buyer_last_name" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Nom</label>
<input type="text" id="buyer_last_name" name="last_name" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;"
placeholder="Dupont">
</div>
<div style="flex:1;min-width:150px;">
<label for="buyer_first_name" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Prenom</label>
<input type="text" id="buyer_first_name" name="first_name" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;"
placeholder="Jean">
</div>
</div>
<div>
<label for="buyer_email" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Email</label>
<input type="email" id="buyer_email" name="email" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;"
placeholder="jean.dupont@exemple.fr">
</div>
<div style="display:flex;align-items:center;gap:0.75rem;">
<input type="checkbox" id="buyer_verify" name="verify_now" value="1"
style="width:1.25rem;height:1.25rem;border:2px solid #111827;cursor:pointer;">
<label for="buyer_verify" class="text-sm font-bold cursor-pointer">Marquer l'email comme verifie immediatement</label>
</div>
<div style="display:flex;gap:0.75rem;">
<button type="submit"
style="padding:0.75rem 1.5rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fabf04;cursor:pointer;"
class="font-black uppercase text-sm tracking-widest hover:bg-green-500 hover:text-white transition-all">
Creer l'acheteur
</button>
<a href="{{ path('app_admin_buyers') }}"
style="padding:0.75rem 1.5rem;border:3px solid #111827;display:inline-flex;align-items:center;"
class="font-black uppercase text-sm tracking-widest bg-white hover:bg-gray-100 transition-all">
Annuler
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Dashboard{% endblock %}
{% block body %}
<div style="margin-bottom:2rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Dashboard</h1>
<p class="font-bold text-gray-500 italic">Bonjour {{ app.user.firstName }}, bienvenue sur l'administration.</p>
</div>
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:2.5rem;">
<div style="flex:1;min-width:280px;border:4px solid #111827;padding:1.5rem;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<p style="font-size:10px;letter-spacing:0.1em;" class="font-black uppercase text-gray-400">CA HT Global</p>
<p class="text-4xl font-black" style="margin-top:0.5rem;">0,00 &euro;</p>
</div>
<div style="flex:1;min-width:280px;border:4px solid #111827;padding:1.5rem;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fabf04;">
<p style="font-size:10px;letter-spacing:0.1em;" class="font-black uppercase">CA HT Commission</p>
<p class="text-4xl font-black" style="margin-top:0.5rem;">0,00 &euro;</p>
</div>
</div>
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:1rem;">Synchronisation Meilisearch</h2>
<p class="text-sm text-gray-500 font-bold" style="margin-bottom:1rem;">Synchronise manuellement les acheteurs verifies dans l'index Meilisearch.</p>
<form method="post" action="{{ path('app_admin_sync_meilisearch') }}">
<button type="submit" style="padding:0.5rem 1rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fabf04;cursor:pointer;" class="font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Synchroniser les acheteurs</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,88 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Organisateurs{% endblock %}
{% block body %}
<div style="margin-bottom:2rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Organisateurs</h1>
<p class="font-bold text-gray-500 italic">{{ organizers.getTotalItemCount }} organisateur{{ organizers.getTotalItemCount > 1 ? 's' : '' }}.</p>
</div>
<div style="display:flex;gap:0;margin-bottom:2rem;">
<a href="{{ path('app_admin_organizers', {tab: 'pending'}) }}" style="flex:1;text-align:center;padding:0.75rem;border:3px solid #111827;border-right:none;{{ tab == 'pending' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-sm tracking-widest transition-all">En attente</a>
<a href="{{ path('app_admin_organizers', {tab: 'approved'}) }}" style="flex:1;text-align:center;padding:0.75rem;border:3px solid #111827;{{ tab == 'approved' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-sm tracking-widest transition-all">Valides</a>
</div>
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#111827;">
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-white">Organisateur</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-white">Raison sociale</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-white">SIRET</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-white">Ville</th>
<th style="padding:0.75rem 1.5rem;text-align:left;" class="text-[10px] font-black uppercase tracking-widest text-white">Offre</th>
<th style="padding:0.75rem 1.5rem;text-align:right;" class="text-[10px] font-black uppercase tracking-widest text-white">Actions</th>
</tr>
</thead>
<tbody>
{% for orga in organizers %}
<tr style="border-bottom:1px solid #e5e7eb;" class="hover:bg-gray-50 transition-all">
<td style="padding:0.75rem 1.5rem;">
<p class="font-bold text-sm">{{ orga.firstName }} {{ orga.lastName }}</p>
<p class="text-gray-400 text-xs">{{ orga.email }}</p>
</td>
<td style="padding:0.75rem 1.5rem;" class="text-sm text-gray-600">{{ orga.companyName }}</td>
<td style="padding:0.75rem 1.5rem;" class="text-sm text-gray-600 font-mono">{{ orga.siret }}</td>
<td style="padding:0.75rem 1.5rem;" class="text-sm text-gray-600">{{ orga.postalCode }} {{ orga.city }}</td>
<td style="padding:0.75rem 1.5rem;">
{% if orga.offer %}
<span style="background:#e0e7ff;border:2px solid #111827;padding:0.15rem 0.5rem;" class="text-xs font-black uppercase">{{ orga.offer }}</span>
{% else %}
<span class="text-gray-400 text-xs">—</span>
{% endif %}
</td>
<td style="padding:0.75rem 1.5rem;text-align:right;">
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
{% if not orga.approved %}
<form method="post" action="{{ path('app_admin_approve_organizer', {id: orga.id}) }}">
<button type="submit" style="border:2px solid #111827;padding:0.4rem 0.75rem;background:#fabf04;cursor:pointer;" class="text-xs font-black uppercase tracking-widest hover:bg-green-500 hover:text-black transition-all">Approuver</button>
</form>
<form method="post" action="{{ path('app_admin_reject_organizer', {id: orga.id}) }}" data-confirm="Etes-vous sur de vouloir refuser et supprimer le compte de {{ orga.firstName }} {{ orga.lastName }} ? Cette action est irreversible.">
<button type="submit" style="border:2px solid #991b1b;padding:0.4rem 0.75rem;background:#dc2626;color:white;cursor:pointer;" class="text-xs font-black uppercase tracking-widest hover:bg-red-800 transition-all">Refuser</button>
</form>
{% else %}
<form method="post" action="{{ path('app_admin_delete_buyer', {id: orga.id}) }}" data-confirm="Etes-vous sur de vouloir supprimer le compte de {{ orga.firstName }} {{ orga.lastName }} ({{ orga.email }}) ? Cette action est irreversible.">
<button type="submit" style="border:2px solid #991b1b;padding:0.4rem 0.75rem;background:#dc2626;color:white;cursor:pointer;" class="text-xs font-black uppercase tracking-widest hover:bg-red-800 transition-all">Supprimer</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" style="padding:3rem;text-align:center;" class="text-gray-400 font-bold text-sm">
{% if tab == 'pending' %}
Aucune demande en attente.
{% else %}
Aucun organisateur valide.
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if organizers.getTotalItemCount > 10 %}
<div style="display:flex;justify-content:center;gap:0.5rem;margin-top:1.5rem;">
{% for page in 1..organizers.getPageCount %}
{% if page == organizers.getCurrentPageNumber %}
<span style="border:2px solid #111827;padding:0.4rem 0.75rem;background:#fabf04;" class="text-xs font-black">{{ page }}</span>
{% else %}
<a href="{{ path('app_admin_organizers', {tab: tab, page: page}) }}" style="border:2px solid #111827;padding:0.4rem 0.75rem;background:white;" class="text-xs font-black hover:bg-gray-100 transition-all">{{ page }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Utilisateurs{% endblock %}
{% block body %}
<div class="mb-8">
<h1 class="text-3xl font-black uppercase tracking-tighter italic">Utilisateurs</h1>
<p class="text-white/60 font-bold mt-1">{{ users|length }} utilisateur{{ users|length > 1 ? 's' : '' }} enregistre{{ users|length > 1 ? 's' : '' }}.</p>
</div>
<div class="glass p-6">
<div class="overflow-x-auto">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr class="text-white/60 text-xs font-bold uppercase tracking-widest">
<th style="padding:12px;text-align:left;border-bottom:1px solid rgba(255,255,255,0.1);">Nom</th>
<th style="padding:12px;text-align:left;border-bottom:1px solid rgba(255,255,255,0.1);">Email</th>
<th style="padding:12px;text-align:left;border-bottom:1px solid rgba(255,255,255,0.1);">Roles</th>
<th style="padding:12px;text-align:left;border-bottom:1px solid rgba(255,255,255,0.1);">Verifie</th>
<th style="padding:12px;text-align:left;border-bottom:1px solid rgba(255,255,255,0.1);">Approuve</th>
<th style="padding:12px;text-align:left;border-bottom:1px solid rgba(255,255,255,0.1);">Inscription</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="hover:bg-white/5 transition-all">
<td style="padding:12px;border-bottom:1px solid rgba(255,255,255,0.05);" class="font-bold">{{ user.firstName }} {{ user.lastName }}</td>
<td style="padding:12px;border-bottom:1px solid rgba(255,255,255,0.05);" class="text-white/70">{{ user.email }}</td>
<td style="padding:12px;border-bottom:1px solid rgba(255,255,255,0.05);">
{% for role in user.roles %}
<span class="inline-block px-2 py-0.5 text-xs font-bold rounded-lg {{ role == 'ROLE_ROOT' ? 'bg-red-500/20 text-red-400' : (role == 'ROLE_ORGANIZER' ? 'bg-indigo-500/20 text-indigo-400' : 'bg-white/10 text-white/60') }}">{{ role }}</span>
{% endfor %}
</td>
<td style="padding:12px;border-bottom:1px solid rgba(255,255,255,0.05);">
{% if user.verified %}<span class="text-green-400">&#10003;</span>{% else %}<span class="text-white/30">&#10005;</span>{% endif %}
</td>
<td style="padding:12px;border-bottom:1px solid rgba(255,255,255,0.05);">
{% if user.approved %}<span class="text-green-400">&#10003;</span>{% else %}<span class="text-white/30">&#10005;</span>{% endif %}
</td>
<td style="padding:12px;border-bottom:1px solid rgba(255,255,255,0.05);" class="text-white/50 text-sm">{{ user.createdAt|date('d/m/Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -150,7 +150,8 @@
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:1.5rem;">
<div>
<p class="font-black uppercase text-sm">&copy; {{ "now"|date("Y") }} E-COSPLAY.</p>
<p class="font-black uppercase text-sm">&copy; {{ "now"|date("Y") }} E-TICKET.</p>
<p style="font-size:11px;" class="font-bold opacity-80">Solution proposee par l'association <a href="https://www.e-cosplay.fr" class="underline hover:no-underline">e-cosplay.fr</a></p>
<p style="font-size:10px;" class="font-bold opacity-70">RNA N&deg;W022006988</p>
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">

View File

@@ -0,0 +1,13 @@
{% extends 'email/base.html.twig' %}
{% block title %}Reinitialisation de votre mot de passe{% endblock %}
{% block content %}
<h2>Bonjour {{ firstName }} !</h2>
<p>Un administrateur E-Ticket a initie une reinitialisation de votre mot de passe pour le compte associe a l'adresse <strong>{{ email }}</strong>.</p>
<p>Pour definir un nouveau mot de passe, cliquez sur le bouton ci-dessous :</p>
<p style="text-align:center;margin:32px 0;">
<a href="{{ resetUrl }}" class="btn">Reinitialiser mon mot de passe</a>
</p>
<p style="font-size:14px;color:#a1a1aa;">Si vous n'avez pas fait cette demande, vous pouvez ignorer cet email. Votre mot de passe actuel restera inchange.</p>
{% endblock %}

View File

@@ -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;
}
</style>
</head>
@@ -86,15 +85,17 @@
<div class="wrapper">
<div class="container">
<div class="header">
<h1>🎫 E-Ticket</h1>
<img src="https://ticket.e-cosplay.fr/logo.png" alt="E-Ticket">
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
<div class="footer">
{% block footer %}
<p>&copy; {{ "now"|date("Y") }} E-Ticket — <a href="https://ticket.e-cosplay.fr">e-cosplay.fr</a></p>
<p>Cet email a été envoyé depuis contact@e-cosplay.fr</p>
<p>&copy; {{ "now"|date("Y") }} E-TICKET — <a href="https://ticket.e-cosplay.fr">ticket.e-cosplay.fr</a></p>
<p>E-Ticket, solution proposee par l'association <a href="https://www.e-cosplay.fr">e-cosplay.fr</a></p>
<p>42 rue de Saint-Quentin, 02800 Beautor, France</p>
<p style="margin-top:8px;font-size:10px;opacity:0.7;">Cet email a ete envoye depuis contact@e-cosplay.fr</p>
{% endblock %}
</div>
</div>

View File

@@ -0,0 +1,12 @@
{% extends 'email/base.html.twig' %}
{% block title %}Votre compte organisateur a ete approuve{% endblock %}
{% block content %}
<h2>Felicitations {{ firstName }} !</h2>
<p>Votre demande de compte organisateur a ete <strong>approuvee</strong> par l'equipe E-Ticket.</p>
<p>Vous pouvez desormais vous connecter et commencer a creer vos evenements.</p>
<p style="text-align:center;margin:32px 0;">
<a href="{{ loginUrl }}" class="btn">Se connecter</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'email/base.html.twig' %}
{% block title %}Votre demande de compte organisateur a ete refusee{% endblock %}
{% block content %}
<h2>Bonjour {{ firstName }},</h2>
<p>Nous avons examine votre demande de compte organisateur et malheureusement, celle-ci a ete <strong>refusee</strong>.</p>
<p>Si vous pensez qu'il s'agit d'une erreur ou si vous souhaitez obtenir plus d'informations, n'hesitez pas a nous contacter.</p>
<p style="text-align:center;margin:32px 0;">
<a href="mailto:contact@e-cosplay.fr" class="btn">Nous contacter</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,350 @@
<?php
namespace App\Tests\Controller;
use App\Entity\User;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class AdminControllerTest extends WebTestCase
{
public function testDashboardRedirectsWhenNotAuthenticated(): void
{
$client = static::createClient();
$client->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<string> $roles
*/
private function createUser(array $roles = []): User
{
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = new User();
$user->setEmail('test-admin-'.uniqid().'@example.com');
$user->setFirstName('Admin');
$user->setLastName('User');
$user->setPassword('$2y$13$hashed');
$user->setRoles($roles);
$em->persist($user);
$em->flush();
return $user;
}
private function createOrganizer(EntityManagerInterface $em): User
{
$orga = new User();
$orga->setEmail('test-orga-'.uniqid().'@example.com');
$orga->setFirstName('Orga');
$orga->setLastName('Test');
$orga->setPassword('$2y$13$hashed');
$orga->setRoles(['ROLE_ORGANIZER']);
$orga->setIsVerified(true);
$orga->setCompanyName('Mon Asso');
$orga->setSiret('12345678901234');
$em->persist($orga);
$em->flush();
return $orga;
}
}

View File

@@ -21,8 +21,8 @@ class MeilisearchMessageHandlerTest extends TestCase
public function testHandleCreateIndex(): void
{
$this->meilisearch->expects(self::once())
->method('createIndex')
->with('events', 'uid');
->method('request')
->with('POST', '/indexes', ['uid' => 'events', 'primaryKey' => 'uid']);
($this->handler)(new MeilisearchMessage('createIndex', 'events', ['primaryKey' => 'uid']));
}
@@ -30,8 +30,8 @@ class MeilisearchMessageHandlerTest extends TestCase
public function testHandleDeleteIndex(): void
{
$this->meilisearch->expects(self::once())
->method('deleteIndex')
->with('events');
->method('request')
->with('DELETE', '/indexes/events');
($this->handler)(new MeilisearchMessage('deleteIndex', 'events'));
}
@@ -40,8 +40,8 @@ class MeilisearchMessageHandlerTest extends TestCase
{
$docs = [['id' => 1]];
$this->meilisearch->expects(self::once())
->method('addDocuments')
->with('events', $docs);
->method('request')
->with('POST', '/indexes/events/documents', $docs);
($this->handler)(new MeilisearchMessage('addDocuments', 'events', ['documents' => $docs]));
}
@@ -50,8 +50,8 @@ class MeilisearchMessageHandlerTest extends TestCase
{
$docs = [['id' => 1, 'title' => 'Updated']];
$this->meilisearch->expects(self::once())
->method('updateDocuments')
->with('events', $docs);
->method('request')
->with('PUT', '/indexes/events/documents', $docs);
($this->handler)(new MeilisearchMessage('updateDocuments', 'events', ['documents' => $docs]));
}
@@ -59,8 +59,8 @@ class MeilisearchMessageHandlerTest extends TestCase
public function testHandleDeleteDocument(): void
{
$this->meilisearch->expects(self::once())
->method('deleteDocument')
->with('events', 42);
->method('request')
->with('DELETE', '/indexes/events/documents/42');
($this->handler)(new MeilisearchMessage('deleteDocument', 'events', ['documentId' => 42]));
}
@@ -68,8 +68,8 @@ class MeilisearchMessageHandlerTest extends TestCase
public function testHandleDeleteDocuments(): void
{
$this->meilisearch->expects(self::once())
->method('deleteDocuments')
->with('events', [1, 2, 3]);
->method('request')
->with('POST', '/indexes/events/documents/delete-batch', [1, 2, 3]);
($this->handler)(new MeilisearchMessage('deleteDocuments', 'events', ['ids' => [1, 2, 3]]));
}
@@ -78,8 +78,8 @@ class MeilisearchMessageHandlerTest extends TestCase
{
$settings = ['searchableAttributes' => ['title']];
$this->meilisearch->expects(self::once())
->method('updateSettings')
->with('events', $settings);
->method('request')
->with('PATCH', '/indexes/events/settings', $settings);
($this->handler)(new MeilisearchMessage('updateSettings', 'events', ['settings' => $settings]));
}

View File

@@ -42,6 +42,7 @@ export default defineConfig({
rollupOptions: {
input: {
app: resolve(__dirname, 'assets/app.js'),
admin: resolve(__dirname, 'assets/admin.js'),
}
},
},