From 4e2f181dd90225d6897d82ecbc167ff4f438b653 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 5 May 2026 10:00:56 +0200 Subject: [PATCH] feat: manage script mappings from admin --- src/Controller/Admin/DashboardController.php | 7 +- .../Admin/ScriptMappingController.php | 86 +++++++++++++++++ src/Form/ScriptMappingType.php | 69 ++++++++++++++ src/Repository/ScriptMappingRepository.php | 6 ++ templates/admin/dashboard.html.twig | 46 +++++++++ templates/admin/mapping_form.html.twig | 19 ++++ .../Admin/ScriptMappingControllerTest.php | 94 +++++++++++++++++++ 7 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 src/Controller/Admin/ScriptMappingController.php create mode 100644 src/Form/ScriptMappingType.php create mode 100644 templates/admin/mapping_form.html.twig create mode 100644 tests/Controller/Admin/ScriptMappingControllerTest.php diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index dca6719..49a415a 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -2,6 +2,7 @@ namespace App\Controller\Admin; +use App\Repository\ScriptMappingRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -9,8 +10,10 @@ use Symfony\Component\Routing\Attribute\Route; final class DashboardController extends AbstractController { #[Route('/admin', name: 'admin_dashboard', methods: ['GET'])] - public function __invoke(): Response + public function __invoke(ScriptMappingRepository $mappingRepository): Response { - return $this->render('admin/dashboard.html.twig'); + return $this->render('admin/dashboard.html.twig', [ + 'mappings' => $mappingRepository->findAllOrderedByPublicPath(), + ]); } } diff --git a/src/Controller/Admin/ScriptMappingController.php b/src/Controller/Admin/ScriptMappingController.php new file mode 100644 index 0000000..62503f5 --- /dev/null +++ b/src/Controller/Admin/ScriptMappingController.php @@ -0,0 +1,86 @@ +createForm(ScriptMappingType::class, $mapping); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $token = (string) $form->get('accessToken')->getData(); + $mapping->setAccessToken($token); + + $this->entityManager->persist($mapping); + $this->entityManager->flush(); + + $this->addFlash('success', 'Mapping créé.'); + + return $this->redirectToRoute('admin_dashboard'); + } + + return $this->render('admin/mapping_form.html.twig', [ + 'form' => $form, + 'mapping' => $mapping, + 'title' => 'Nouveau mapping', + ], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK)); + } + + #[Route('/{id}/edit', name: 'admin_mapping_edit', methods: ['GET', 'POST'])] + public function edit(Request $request, ScriptMapping $mapping): Response + { + $form = $this->createForm(ScriptMappingType::class, $mapping); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $token = (string) $form->get('accessToken')->getData(); + if ($token !== '') { + $mapping->setAccessToken($token); + } + + $this->entityManager->flush(); + + $this->addFlash('success', 'Mapping mis à jour.'); + + return $this->redirectToRoute('admin_dashboard'); + } + + return $this->render('admin/mapping_form.html.twig', [ + 'form' => $form, + 'mapping' => $mapping, + 'title' => 'Modifier le mapping', + ], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK)); + } + + #[Route('/{id}/delete', name: 'admin_mapping_delete', methods: ['POST'])] + public function delete(Request $request, ScriptMapping $mapping): Response + { + if (!$this->isCsrfTokenValid('delete_mapping_'.$mapping->getId(), (string) $request->request->get('_token'))) { + throw $this->createAccessDeniedException(); + } + + $this->entityManager->remove($mapping); + $this->entityManager->flush(); + + $this->addFlash('success', 'Mapping supprimé.'); + + return $this->redirectToRoute('admin_dashboard'); + } +} diff --git a/src/Form/ScriptMappingType.php b/src/Form/ScriptMappingType.php new file mode 100644 index 0000000..1e334cc --- /dev/null +++ b/src/Form/ScriptMappingType.php @@ -0,0 +1,69 @@ +add('publicPath', TextType::class) + ->add('repositoryUrl', UrlType::class, [ + 'default_protocol' => null, + ]) + ->add('gitRef', TextType::class) + ->add('repositoryFilePath', TextType::class) + ->add('accessToken', PasswordType::class, [ + 'mapped' => false, + 'required' => false, + ]) + ->add('active', CheckboxType::class, [ + 'required' => false, + ]) + ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void { + $mapping = $event->getData(); + if (!$mapping instanceof ScriptMapping) { + return; + } + + $form = $event->getForm(); + + try { + $mapping->setPublicPath($this->pathNormalizer->publicPath($mapping->getPublicPath())); + } catch (InvalidArgumentException $exception) { + $form->get('publicPath')->addError(new FormError($exception->getMessage())); + } + + try { + $mapping->setRepositoryFilePath($this->pathNormalizer->repositoryPath($mapping->getRepositoryFilePath())); + } catch (InvalidArgumentException $exception) { + $form->get('repositoryFilePath')->addError(new FormError($exception->getMessage())); + } + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ScriptMapping::class, + ]); + } +} diff --git a/src/Repository/ScriptMappingRepository.php b/src/Repository/ScriptMappingRepository.php index 4f41cad..5e8b6b7 100644 --- a/src/Repository/ScriptMappingRepository.php +++ b/src/Repository/ScriptMappingRepository.php @@ -20,4 +20,10 @@ final class ScriptMappingRepository extends ServiceEntityRepository 'active' => true, ]); } + + /** @return list */ + public function findAllOrderedByPublicPath(): array + { + return $this->findBy([], ['publicPath' => 'ASC']); + } } diff --git a/templates/admin/dashboard.html.twig b/templates/admin/dashboard.html.twig index e74ea46..40ed249 100644 --- a/templates/admin/dashboard.html.twig +++ b/templates/admin/dashboard.html.twig @@ -5,5 +5,51 @@ {% block body %}

Mappings

+

Nouveau mapping

+ + {% for message in app.flashes('success') %} +

{{ message }}

+ {% endfor %} + + + + + + + + + + + + + + + + + {% for mapping in mappings %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
Chemin publicDépôtRéf.FichierActifDernière synchroDernier succèsErreurActions
{{ mapping.publicPath }}{{ mapping.repositoryUrl }}{{ mapping.gitRef }}{{ mapping.repositoryFilePath }}{{ mapping.active ? 'oui' : 'non' }}{{ mapping.lastSyncStatus }}{{ mapping.lastSuccessfulSyncAt ? mapping.lastSuccessfulSyncAt|date('Y-m-d H:i:s') : '' }}{{ mapping.lastSyncError }} + Modifier +
+ + +
+
Aucun mapping.
{% endblock %} diff --git a/templates/admin/mapping_form.html.twig b/templates/admin/mapping_form.html.twig new file mode 100644 index 0000000..9f17cc2 --- /dev/null +++ b/templates/admin/mapping_form.html.twig @@ -0,0 +1,19 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block body %} +
+

{{ title }}

+ + {{ form_start(form) }} + {{ form_row(form.publicPath) }} + {{ form_row(form.repositoryUrl) }} + {{ form_row(form.gitRef) }} + {{ form_row(form.repositoryFilePath) }} + {{ form_row(form.accessToken) }} + {{ form_row(form.active) }} + + {{ form_end(form) }} +
+{% endblock %} diff --git a/tests/Controller/Admin/ScriptMappingControllerTest.php b/tests/Controller/Admin/ScriptMappingControllerTest.php new file mode 100644 index 0000000..8338cc3 --- /dev/null +++ b/tests/Controller/Admin/ScriptMappingControllerTest.php @@ -0,0 +1,94 @@ +client = self::createClient(); + + $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropSchema($metadata); + $schemaTool->createSchema($metadata); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->entityManager->close(); + } + + public function testNewMappingRequiresAuthentication(): void + { + $this->client->request('GET', '/admin/mappings/new'); + + self::assertResponseRedirects('/admin/login'); + } + + public function testAdminCanCreateMappingWithNormalizedPaths(): void + { + $this->loginAsAdmin(); + + $crawler = $this->client->request('GET', '/admin/mappings/new'); + $form = $crawler->selectButton('Enregistrer')->form([ + 'script_mapping[publicPath]' => '/mcp/graylog/install.sh', + 'script_mapping[repositoryUrl]' => 'https://forge.lclr.dev/AI/graylog-mcp.git', + 'script_mapping[gitRef]' => 'main', + 'script_mapping[repositoryFilePath]' => '/install.sh', + 'script_mapping[active]' => '1', + ]); + + $this->client->submit($form); + + self::assertResponseRedirects('/admin'); + + $mapping = $this->entityManager->getRepository(ScriptMapping::class)->findOneBy([ + 'publicPath' => 'mcp/graylog/install.sh', + ]); + self::assertInstanceOf(ScriptMapping::class, $mapping); + self::assertSame('install.sh', $mapping->getRepositoryFilePath()); + } + + public function testInvalidPublicPathRendersValidationError(): void + { + $this->loginAsAdmin(); + + $crawler = $this->client->request('GET', '/admin/mappings/new'); + $form = $crawler->selectButton('Enregistrer')->form([ + 'script_mapping[publicPath]' => 'mcp/graylog/install.txt', + 'script_mapping[repositoryUrl]' => 'https://forge.lclr.dev/AI/graylog-mcp.git', + 'script_mapping[gitRef]' => 'main', + 'script_mapping[repositoryFilePath]' => 'install.sh', + 'script_mapping[active]' => '1', + ]); + + $this->client->submit($form); + + self::assertResponseStatusCodeSame(422); + self::assertSelectorTextContains('form', 'Public path must end with .sh.'); + } + + private function loginAsAdmin(): void + { + $user = (new User())->setUsername('admin')->setPasswordHash('unused'); + $this->entityManager->persist($user); + $this->entityManager->flush(); + + $this->client->loginUser($user); + } +}