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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
11
assets/admin.js
Normal 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
1
assets/admin.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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
166
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -27,6 +27,7 @@ security:
|
||||
target: app_home
|
||||
|
||||
access_control:
|
||||
- { path: ^/admin, roles: ROLE_ROOT }
|
||||
- { path: ^/mon-compte, roles: ROLE_USER }
|
||||
|
||||
when@test:
|
||||
|
||||
294
src/Controller/AdminController.php
Normal file
294
src/Controller/AdminController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
47
templates/admin/base.html.twig
Normal file
47
templates/admin/base.html.twig
Normal 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>
|
||||
111
templates/admin/buyers.html.twig
Normal file
111
templates/admin/buyers.html.twig
Normal 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 %}
|
||||
57
templates/admin/create_buyer.html.twig
Normal file
57
templates/admin/create_buyer.html.twig
Normal 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 %}
|
||||
29
templates/admin/dashboard.html.twig
Normal file
29
templates/admin/dashboard.html.twig
Normal 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 €</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 €</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 %}
|
||||
88
templates/admin/organizers.html.twig
Normal file
88
templates/admin/organizers.html.twig
Normal 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 %}
|
||||
47
templates/admin/users.html.twig
Normal file
47
templates/admin/users.html.twig
Normal 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">✓</span>{% else %}<span class="text-white/30">✕</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">✓</span>{% else %}<span class="text-white/30">✕</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 %}
|
||||
@@ -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">© {{ "now"|date("Y") }} E-COSPLAY.</p>
|
||||
<p class="font-black uppercase text-sm">© {{ "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°W022006988</p>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">
|
||||
|
||||
13
templates/email/admin_reset_password.html.twig
Normal file
13
templates/email/admin_reset_password.html.twig
Normal 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 %}
|
||||
@@ -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>© {{ "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>© {{ "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>
|
||||
|
||||
12
templates/email/organizer_approved.html.twig
Normal file
12
templates/email/organizer_approved.html.twig
Normal 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 %}
|
||||
12
templates/email/organizer_rejected.html.twig
Normal file
12
templates/email/organizer_rejected.html.twig
Normal 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 %}
|
||||
350
tests/Controller/AdminControllerTest.php
Normal file
350
tests/Controller/AdminControllerTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
app: resolve(__dirname, 'assets/app.js'),
|
||||
admin: resolve(__dirname, 'assets/admin.js'),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user