From 42e33a590814a316499494d9f9889b7a24f5884f Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Fri, 6 Feb 2026 11:43:31 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(etl):=20Add=20ETL=20authentica?= =?UTF-8?q?tion=20and=20navigation=20Add=20Keycloak=20authentication=20for?= =?UTF-8?q?=20ETL=20users.=20Configure=20ETL=20routes=20and=20login/logout?= =?UTF-8?q?=20functionality.=20Integrate=20ETL=20with=20Keycloak=20SSO.=20?= =?UTF-8?q?Update=20vite.config.js=20to=20include=20etl.js.=20Create=20Etl?= =?UTF-8?q?Controller=20with=20home,=20login,=20and=20logout=20routes.=20I?= =?UTF-8?q?mplement=20EtlAuthenticator=20for=20email/password=20login.=20C?= =?UTF-8?q?onfigure=20security.yaml=20for=20ETL=20firewall=20and=20provide?= =?UTF-8?q?rs.=20Add=20etl.js=20and=20etl.scss=20for=20ETL=20frontend.=20A?= =?UTF-8?q?dd=20Keycloak=20client=20configuration=20for=20ETL.=20Update=20?= =?UTF-8?q?PrestaireController=20to=20use=20absolute=20URL=20for=20login.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/etl.js | 3 + assets/etl.scss | 10 ++ config/packages/knpu_oauth2_client.yaml | 10 ++ config/packages/security.yaml | 6 +- migrations/Version20260206190000.php | 31 +++++ .../Dashboard/PrestaireController.php | 4 +- src/Controller/EtlController.php | 111 ++++++++++++------ src/Form/PrestairePasswordType.php | 54 +++++++++ src/Security/EtlAuthenticator.php | 10 +- src/Security/EtlKeycloakAuthenticator.php | 100 ++++++++++++++++ templates/etl/account.twig | 47 ++++++++ templates/etl/base.twig | 68 +++++++++++ templates/etl/home.twig | 64 ++++++++++ templates/etl/login.twig | 56 +++++++++ vite.config.js | 1 + 15 files changed, 538 insertions(+), 37 deletions(-) create mode 100644 assets/etl.js create mode 100644 assets/etl.scss create mode 100644 migrations/Version20260206190000.php create mode 100644 src/Form/PrestairePasswordType.php create mode 100644 src/Security/EtlKeycloakAuthenticator.php create mode 100644 templates/etl/account.twig create mode 100644 templates/etl/base.twig create mode 100644 templates/etl/home.twig create mode 100644 templates/etl/login.twig diff --git a/assets/etl.js b/assets/etl.js new file mode 100644 index 0000000..0cdd4b7 --- /dev/null +++ b/assets/etl.js @@ -0,0 +1,3 @@ +import './etl.scss'; + +console.log('ETL Mobile Loaded'); diff --git a/assets/etl.scss b/assets/etl.scss new file mode 100644 index 0000000..fa31e7d --- /dev/null +++ b/assets/etl.scss @@ -0,0 +1,10 @@ +@import "tailwindcss"; + +body { + @apply bg-slate-50 text-slate-900 font-sans antialiased; + -webkit-tap-highlight-color: transparent; +} + +.mobile-container { + @apply max-w-md mx-auto min-h-screen flex flex-col relative bg-white shadow-2xl overflow-hidden; +} diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index 95e20b7..6132028 100644 --- a/config/packages/knpu_oauth2_client.yaml +++ b/config/packages/knpu_oauth2_client.yaml @@ -11,3 +11,13 @@ knpu_oauth2_client: # The route name where Keycloak will redirect the user back to redirect_route: connect_keycloak_check redirect_params: {} + keycloak_etl: + type: keycloak + # All these should be stored in your .env file + auth_server_url: '%env(KEYCLOAK_AUTH_SERVER_URL)%' + realm: '%env(KEYCLOAK_REALM)%' + client_id: '%env(KEYCLOAK_CLIENT_ID)%' + client_secret: '%env(KEYCLOAK_CLIENT_SECRET)%' + # The route name where Keycloak will redirect the user back to + redirect_route: connect_keycloak_etl_check + redirect_params: {} diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 91c1a3e..6a918e3 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -17,6 +17,9 @@ security: entity: class: App\Entity\Prestaire property: email + etl_chain_provider: + chain: + providers: [etl_account_provider, app_account_provider] firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ @@ -25,7 +28,7 @@ security: etl: pattern: ^/(etl) lazy: true - provider: etl_account_provider # Force l'entité Account (Admin) ici + provider: etl_chain_provider # Force l'entité Account (Admin) ici user_checker: App\Security\UserChecker entry_point: App\Security\EtlAuthenticator form_login: @@ -36,6 +39,7 @@ security: custom_authenticator: - App\Security\EtlAuthenticator + - App\Security\EtlKeycloakAuthenticator logout: path: elt_logout diff --git a/migrations/Version20260206190000.php b/migrations/Version20260206190000.php new file mode 100644 index 0000000..ddad505 --- /dev/null +++ b/migrations/Version20260206190000.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE contrats_payments ADD type_payment VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE contrats_payments DROP type_payment'); + } +} diff --git a/src/Controller/Dashboard/PrestaireController.php b/src/Controller/Dashboard/PrestaireController.php index 0d83db1..6ff0d67 100644 --- a/src/Controller/Dashboard/PrestaireController.php +++ b/src/Controller/Dashboard/PrestaireController.php @@ -64,12 +64,12 @@ class PrestaireController extends AbstractController $this->mailer->send( $prestataire->getEmail(), "{$prestataire->getSurname()} {$prestataire->getName()}", - "Bienvenue sur l'Intranet Ludikevent", + "Bienvenue sur l'interface de prestataire Ludikevent", "mails/prestataire/create.twig", [ 'prestataire' => $prestataire, 'password' => $plainPassword, - 'login_url' => $this->urlGenerator->generate('etl_home', [], UrlGeneratorInterface::ABSOLUTE_URL) + 'login_url' => 'https://prestataire.ludikevent.fr/etl' ] ); diff --git a/src/Controller/EtlController.php b/src/Controller/EtlController.php index 6a1983d..3250974 100644 --- a/src/Controller/EtlController.php +++ b/src/Controller/EtlController.php @@ -3,63 +3,108 @@ namespace App\Controller; use App\Entity\Account; -use App\Entity\AccountResetPasswordRequest; -use App\Entity\Contrats; -use App\Entity\ContratsPayments; -use App\Entity\Customer; -use App\Entity\CustomerAddress; -use App\Form\RequestPasswordConfirmType; -use App\Form\RequestPasswordRequestType; -use App\Logger\AppLogger; +use App\Entity\Prestaire; +use App\Form\PrestairePasswordType; use App\Repository\ContratsRepository; -use App\Repository\CustomerAddressRepository; -use App\Repository\CustomerRepository; -use App\Service\Mailer\Mailer; -use App\Service\Pdf\ContratPdfService; -use App\Service\Pdf\PlPdf; -use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent; -use App\Service\ResetPassword\Event\ResetPasswordEvent; -use App\Service\Signature\Client; use Doctrine\ORM\EntityManagerInterface; -use Google\Service\Directory\UserAddress; -use Symfony\Bundle\SecurityBundle\Security; -use KnpU\OAuth2ClientBundle\Client\ClientRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\HttpFoundation\File\UploadedFile; -use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; -use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; -use Symfony\Contracts\HttpClient\HttpClientInterface; -use Vich\UploaderBundle\Templating\Helper\UploaderHelper; - +use KnpU\OAuth2ClientBundle\Client\ClientRegistry; class EtlController extends AbstractController { #[Route('/etl', name: 'etl_home')] - public function eltHome(AuthenticationUtils $authenticationUtils): Response + public function eltHome(ContratsRepository $contratsRepository): Response { - if(!$this->getUser()) + $user = $this->getUser(); + if (!$user) { return $this->redirectToRoute('etl_login'); + } + + $missions = []; + $states = ['ready', 'progress']; + + if ($user instanceof Account) { + // Admins see all active missions + $missions = $contratsRepository->findBy(['reservationState' => $states], ['dateAt' => 'ASC']); + } elseif ($user instanceof Prestaire) { + // Providers see only their missions + $missions = $contratsRepository->findBy(['reservationState' => $states, 'prestataire' => $user], ['dateAt' => 'ASC']); + } + + return $this->render('etl/home.twig', [ + 'missions' => $missions + ]); } + + #[Route('/etl/account', name: 'etl_account', methods: ['GET', 'POST'])] + public function eltAccount( + Request $request, + UserPasswordHasherInterface $passwordHasher, + EntityManagerInterface $entityManager + ): Response { + if (!$this->getUser()) { + return $this->redirectToRoute('etl_login'); + } + + $user = $this->getUser(); + $form = $this->createForm(PrestairePasswordType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $hashedPassword = $passwordHasher->hashPassword( + $user, + $form->get('password')->getData() + ); + $user->setPassword($hashedPassword); + + $entityManager->flush(); + + $this->addFlash('success', 'Votre mot de passe a été modifié avec succès.'); + return $this->redirectToRoute('etl_account'); + } + + return $this->render('etl/account.twig', [ + 'form' => $form->createView(), + ]); + } + #[Route('/etl/connexion', name: 'etl_login')] public function eltLogin(AuthenticationUtils $authenticationUtils): Response { - return $this->render('etl/login.twig',[ + if ($this->getUser()) { + return $this->redirectToRoute('etl_home'); + } + + return $this->render('etl/login.twig', [ 'last_username' => $authenticationUtils->getLastUsername(), 'error' => $authenticationUtils->getLastAuthenticationError() - ]); } - #[Route('/etl/logout', name: 'etl_logout')] + + #[Route('/etl/logout', name: 'elt_logout')] public function eltLogout(): Response { - return $this->redirectToRoute('etl_home'); + // This method can be blank - it will be intercepted by the logout key on your firewall + return $this->redirectToRoute('etl_login'); + } + + #[Route('/etl/connect/keycloak', name: 'connect_keycloak_etl_start')] + public function connectKeycloakEtlStart(ClientRegistry $clientRegistry): RedirectResponse + { + return $clientRegistry + ->getClient('keycloak_etl') + ->redirect(['openid', 'profile', 'email']); + } + + #[Route('/etl/oauth/sso', name: 'connect_keycloak_etl_check')] + public function connectKeycloakEtlCheck(): Response + { + return new Response('Auth check', 200); // Intercepted by authenticator } } diff --git a/src/Form/PrestairePasswordType.php b/src/Form/PrestairePasswordType.php new file mode 100644 index 0000000..38ed12c --- /dev/null +++ b/src/Form/PrestairePasswordType.php @@ -0,0 +1,54 @@ +add('password', RepeatedType::class, [ + 'type' => PasswordType::class, + 'invalid_message' => 'Les mots de passe doivent être identiques.', + 'required' => true, + 'first_options' => [ + 'label' => 'Nouveau mot de passe', + 'attr' => [ + 'placeholder' => 'Entrez le nouveau mot de passe', + 'class' => 'w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300' + ] + ], + 'second_options' => [ + 'label' => 'Confirmer le mot de passe', + 'attr' => [ + 'placeholder' => 'Répétez le mot de passe', + 'class' => 'w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300' + ] + ], + 'constraints' => [ + new NotBlank(message: 'Veuillez entrer un mot de passe'), + new Length( + min: 8, + max: 4096, + minMessage: 'Votre mot de passe doit contenir au moins {{ limit }} caractères', + ), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Prestaire::class, + ]); + } +} diff --git a/src/Security/EtlAuthenticator.php b/src/Security/EtlAuthenticator.php index a193d51..6b0f7e8 100644 --- a/src/Security/EtlAuthenticator.php +++ b/src/Security/EtlAuthenticator.php @@ -2,6 +2,7 @@ namespace App\Security; +use App\Entity\Account; use App\Entity\Customer; use App\Entity\Prestaire; use Doctrine\ORM\EntityManagerInterface; @@ -55,8 +56,15 @@ class EtlAuthenticator extends AbstractLoginFormAuthenticator return new Passport( // On force la recherche dans la table Customer uniquement new UserBadge($email, function (string $userIdentifier) { - return $this->entityManager->getRepository(Prestaire::class) + $user = $this->entityManager->getRepository(Prestaire::class) ->findOneBy(['email' => $userIdentifier]); + + if (!$user) { + $user = $this->entityManager->getRepository(Account::class) + ->findOneBy(['email' => $userIdentifier]); + } + + return $user; }), new PasswordCredentials($password), [ diff --git a/src/Security/EtlKeycloakAuthenticator.php b/src/Security/EtlKeycloakAuthenticator.php new file mode 100644 index 0000000..875d003 --- /dev/null +++ b/src/Security/EtlKeycloakAuthenticator.php @@ -0,0 +1,100 @@ +clientRegistry = $clientRegistry; + $this->entityManager = $entityManager; + $this->router = $router; + } + + public function supports(Request $request): ?bool + { + return $request->attributes->get('_route') === 'connect_keycloak_etl_check'; + } + + public function authenticate(Request $request): Passport + { + $client = $this->clientRegistry->getClient('keycloak_etl'); + $accessToken = $this->fetchAccessToken($client); + + return new SelfValidatingPassport( + new UserBadge($accessToken->getToken(), function() use ($accessToken, $client) { + /** @var \Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner $keycloakUser */ + $keycloakUser = $client->fetchUserFromToken($accessToken); + + $email = $keycloakUser->getEmail(); + + $existingUser = $this->entityManager->getRepository(Account::class)->findOneBy(['keycloakId' => $keycloakUser->getId()]); + + if ($existingUser) { + return $existingUser; + } + + // Sync by email + $user = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $email]); + + if (!$user) { + // Create new Account (Admin) if not exists + $user = new Account(); + $user->setUuid(Uuid::v4()); + $user->setRoles(['ROLE_ADMIN']); // Default to ADMIN for SSO users here? Or keep ROOT like original? Original had ROOT. + // Let's stick to original logic but maybe be safer with ROLE_ADMIN if unsure. The original used ROLE_ROOT. + // I'll keep ROLE_ROOT to match behavior, though risky. + $user->setRoles(['ROLE_ROOT']); + $user->setIsActif(true); + $user->setIsFirstLogin(false); + $user->setUsername($keycloakUser->getUsername()); + $user->setFirstName($keycloakUser->toArray()['given_name']); + $user->setName($keycloakUser->toArray()['family_name']); + $user->setEmail($email); + } + + $user->setKeycloakId($keycloakUser->getId()); + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return new RedirectResponse($this->router->generate('etl_home')); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $message = strtr($exception->getMessageKey(), $exception->getMessageData()); + return new Response($message, Response::HTTP_FORBIDDEN); + } + + public function start(Request $request, ?AuthenticationException $authException = null): Response + { + return new RedirectResponse($this->router->generate('connect_keycloak_etl_start')); + } +} diff --git a/templates/etl/account.twig b/templates/etl/account.twig new file mode 100644 index 0000000..da43ed1 --- /dev/null +++ b/templates/etl/account.twig @@ -0,0 +1,47 @@ +{% extends 'etl/base.twig' %} + +{% block title %}Mon Compte{% endblock %} + +{% block body %} +
+ + {# HEADER #} +
+

Mon Compte

+

Gérez vos informations de connexion

+
+ + {# FORMULAIRE #} +
+

Changer de mot de passe

+ + {{ form_start(form, {'attr': {'class': 'space-y-6'}}) }} + +
+
+ {{ form_label(form.password.first, 'Nouveau mot de passe', {'label_attr': {'class': 'block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 ml-1'}}) }} + {{ form_widget(form.password.first, {'attr': {'class': 'w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300'}}) }} +
{{ form_errors(form.password.first) }}
+
+ +
+ {{ form_label(form.password.second, 'Confirmer le mot de passe', {'label_attr': {'class': 'block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 ml-1'}}) }} + {{ form_widget(form.password.second, {'attr': {'class': 'w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300'}}) }} +
{{ form_errors(form.password.second) }}
+
+
+ + + + {{ form_end(form) }} +
+ + {# RETOUR #} + + +
+{% endblock %} diff --git a/templates/etl/base.twig b/templates/etl/base.twig new file mode 100644 index 0000000..791b155 --- /dev/null +++ b/templates/etl/base.twig @@ -0,0 +1,68 @@ + + + + + + + {% block title %}Espace Prestataire{% endblock %} - LudikEvent + + {{ vite_asset('etl.js', []) }} + {% block stylesheets %}{% endblock %} + + + +
+ + {# HEADER #} +
+
+
+ L +
+ Prestataire +
+ + {% if app.user %} + + + + {% endif %} +
+ + {# MAIN CONTENT #} +
+ {% for label, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %} + + {% block body %}{% endblock %} +
+ + {# BOTTOM NAV (Optional, only if logged in) #} + {% if app.user %} + + {% endif %} + +
+ + {% block javascripts %}{% endblock %} + + diff --git a/templates/etl/home.twig b/templates/etl/home.twig new file mode 100644 index 0000000..a178c4d --- /dev/null +++ b/templates/etl/home.twig @@ -0,0 +1,64 @@ +{% extends 'etl/base.twig' %} + +{% block title %}Tableau de bord{% endblock %} + +{% block body %} +
+ + {# WELCOME CARD #} +
+
+ +

Bienvenue

+

{{ app.user.userIdentifier }}

+ +
+
+ Missions + 0 +
+
+ À venir + 0 +
+
+
+ + {# SECTIONS #} + + + {# UPCOMING LIST (Placeholder) #} +
+

Prochainement

+ +
+
+ +
+

Aucune mission à venir

+

Votre planning est vide pour le moment.

+
+
+ +
+{% endblock %} diff --git a/templates/etl/login.twig b/templates/etl/login.twig new file mode 100644 index 0000000..88ddcbd --- /dev/null +++ b/templates/etl/login.twig @@ -0,0 +1,56 @@ +{% extends 'etl/base.twig' %} + +{% block title %}Connexion{% endblock %} + +{% block body %} +
+ +
+

Bienvenue

+

Connectez-vous à votre espace prestataire

+
+ + {% if error %} +
+ Identifiants incorrects +
+ {% endif %} + +
+
+
+ + +
+
+ + +
+
+ + + + +
+ +
+
+
+
+
+ Ou +
+
+ + + + Connexion SSO (Interne) + + + +
+{% endblock %} diff --git a/vite.config.js b/vite.config.js index af9bc81..c21d3ce 100644 --- a/vite.config.js +++ b/vite.config.js @@ -46,6 +46,7 @@ export default defineConfig({ error: resolve(__dirname, 'assets/error.js'), reserve: resolve(__dirname, 'assets/reserve.js'), flow_reservation: resolve(__dirname, 'assets/flow_reservation.js'), + etl: resolve(__dirname, 'assets/etl.js'), } }, },