src/Controller/SecurityController.php line 376

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Controller;
  4. use App\Entity\User;
  5. use App\Form\RequestAccessFormType;
  6. use App\Form\SigninRegistrationFormType;
  7. use App\Repository\ClientRepository;
  8. use App\Repository\UserRepository;
  9. use App\Service\AuthMonitoringService;
  10. use App\Service\EmailSenderService;
  11. use App\Service\MagicLinkService;
  12. use App\Service\RecaptchaService;
  13. use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
  14. use MobileDetectBundle\DeviceDetector\MobileDetectorInterface;
  15. use Psr\Log\LoggerInterface;
  16. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  17. use Symfony\Component\HttpFoundation\Request;
  18. use Symfony\Component\HttpFoundation\Response;
  19. use Symfony\Component\HttpFoundation\Session\SessionInterface;
  20. use Symfony\Component\Routing\Annotation\Route;
  21. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  22. use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
  23. class SecurityController extends AbstractController
  24. {
  25.   private $userRepository;
  26.   private $clientRepository;
  27.   private $emailSenderService;
  28.   private $magicLinkService;
  29.   private $logger;
  30.   private $authMonitoring;
  31.   public function __construct(
  32.     UserRepository $userRepository,
  33.     ClientRepository $clientRepository,
  34.     EmailSenderService $emailSenderService,
  35.     MagicLinkService $magicLinkService,
  36.     LoggerInterface $logger,
  37.     AuthMonitoringService $authMonitoring
  38.   ) {
  39.     $this->userRepository $userRepository;
  40.     $this->clientRepository $clientRepository;
  41.     $this->emailSenderService $emailSenderService;
  42.     $this->magicLinkService $magicLinkService;
  43.     $this->logger $logger;
  44.     $this->authMonitoring $authMonitoring;
  45.   }
  46.   /**
  47.    * Page d'accueil et connexion classique (email/password)
  48.    *
  49.    * @Route("/", name="home")
  50.    * @Route("/login", name="app_login")
  51.    */
  52.   public function home(AuthenticationUtils $authenticationUtilsRequest $requestMobileDetectorInterface $mobileDetectorSessionInterface $session): Response
  53.   {
  54.     // Si l'utilisateur est déjà authentifié, rediriger vers le dashboard
  55.     if ($this->getUser()) {
  56.       return $this->redirectToRoute('suggestion.index');
  57.     }
  58.     $isInactive = ($request->query->get('isInactive') != null) ? 0;
  59.     $error $authenticationUtils->getLastAuthenticationError();
  60.     $prefilledEmail $request->query->get('email''');
  61.     $isRedirected = ($request->query->get('redirected') == '1');
  62.     $loginErrorType $request->query->get('login_error'null);
  63.     $showCreateAccountInfo $request->query->get('create_account') === '1';
  64.     $clientCreateAccountUrl null;
  65.     $hideLoginActions false;
  66.     $showMagicLinkConfirmation = ($request->query->get('magic_link_sent') === '1' && $prefilledEmail !== '');
  67.     $magicLinkType $request->query->get('magic_link_type''connexion');
  68.     $showRequestAccessForm = ($request->query->get('show') === 'demander-mon-acces');
  69.     $requestAccessForm $this->createForm(RequestAccessFormType::class);
  70.     $requestAccessForm->handleRequest($request);
  71.     $requestAccessEligible $request->query->get('eligible');
  72.     $requestAccessSigninUrl null;
  73.     if ($showRequestAccessForm && $requestAccessEligible === '1') {
  74.       $requestAccessSigninUrl $session->get('request_access_signin_url');
  75.     }
  76.     // Si un token est présent, récupérer l'email de l'utilisateur correspondant
  77.     $token $request->query->get('token''');
  78.     if (!empty($token) && empty($prefilledEmail)) {
  79.       $user $this->userRepository->findOneBy(['token' => $token]);
  80.       // ✅ Vérifications de sécurité (Option 3)
  81.       if ($user) {
  82.         // Vérifier que le compte est actif
  83.         if (!$user->isIsActive()) {
  84.           $this->addFlash('error''Ce compte est inactif.');
  85.           return $this->redirectToRoute('app_login');
  86.         }
  87.         $prefilledEmail $user->getEmail();
  88.       }
  89.     }
  90.     // Vérifier si l'utilisateur (si email pré-rempli) n'a pas de mot de passe
  91.     // Ce sont les utilisateurs ANCIENS créés avant l'implémentation du système de mot de passe
  92.     $userHasNoPassword false;
  93.     if (!empty($prefilledEmail)) {
  94.       $user $this->userRepository->findOneBy(['email' => $prefilledEmail]);
  95.       if ($user && empty($user->getPassword())) {
  96.         $userHasNoPassword true;
  97.       }
  98.     }
  99.     // Scénario "compte inexistant/non activé" + entreprise détectée via domain_restriction
  100.     if ($loginErrorType === 'account_missing_client' && !empty($prefilledEmail)) {
  101.       $client $this->clientRepository->findClientByEmailDomain($prefilledEmail);
  102.       if ($client) {
  103.         $clientCreateAccountUrl $this->generateUrl('client.signin', [
  104.           'slug' => $client->getSlug(),
  105.           'token' => $client->getTokenCooptor(),
  106.         ]);
  107.         $hideLoginActions true;
  108.       } else {
  109.         // Fallback: si aucun client finalement trouvé, on bascule sur le message "RH"
  110.         $loginErrorType 'account_missing_no_client';
  111.       }
  112.     }
  113.     return $this->render('page/index.html.twig', [
  114.       'error' => $error,
  115.       'isInactive' => $isInactive,
  116.       'prefilledEmail' => $prefilledEmail,
  117.       'isRedirected' => $isRedirected,
  118.       'userHasNoPassword' => $userHasNoPassword,
  119.       'login_error_type' => $loginErrorType,
  120.       'show_create_account_info' => $showCreateAccountInfo,
  121.       'client_create_account_url' => $clientCreateAccountUrl,
  122.       'hide_login_actions' => $hideLoginActions,
  123.       'is_mobile' => $mobileDetector->isMobile(),
  124.       'show_magic_link_confirmation' => $showMagicLinkConfirmation,
  125.       'magic_link_email' => $showMagicLinkConfirmation $prefilledEmail null,
  126.       'magic_link_type' => $showMagicLinkConfirmation $magicLinkType null,
  127.       'show_request_access_form' => $showRequestAccessForm,
  128.       'request_access_form' => $requestAccessForm->createView(),
  129.       'request_access_eligible' => $requestAccessEligible,
  130.       'request_access_signin_url' => $requestAccessSigninUrl,
  131.     ]);
  132.   }
  133.   /**
  134.    * Page "Demander mon accès" : formulaire de vérification d'éligibilité par email professionnel.
  135.    * Redirige vers la page d'accueil avec le formulaire affiché (?show=demander-mon-acces).
  136.    */
  137.   public function requestAccess(Request $requestSessionInterface $session): Response
  138.   {
  139.     if ($this->getUser()) {
  140.       return $this->redirectToRoute('suggestion.index');
  141.     }
  142.     if ($request->isMethod('GET')) {
  143.       return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
  144.     }
  145.     $form $this->createForm(RequestAccessFormType::class);
  146.     $form->handleRequest($request);
  147.     $eligible null;
  148.     $signinUrl null;
  149.     if ($form->isSubmitted() && $form->isValid()) {
  150.       $email = (string) $form->get('email')->getData();
  151.       $email strtolower(trim($email));
  152.       // Si l'utilisateur a déjà un compte, on envoie un lien de connexion (Magic Link)
  153.       // plutôt que de le rediriger vers le flux "inscription" (évite la confusion UX).
  154.       $existingUser $this->userRepository->findOneByLoginEmail($email);
  155.       if ($existingUser !== null) {
  156.         if (!$existingUser->isIsActive()) {
  157.           $this->addFlash('error''Ce compte est inactif.');
  158.           return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
  159.         }
  160.         try {
  161.           $magicToken $this->magicLinkService->generateForExistingUser($existingUser);
  162.           $magicLinkUrl $this->generateUrl('app_magic_link_verify', [], UrlGeneratorInterface::ABSOLUTE_URL) . '#' $magicToken;
  163.           $firstname trim((string) $existingUser->getFirstname());
  164.           $this->emailSenderService->sendTemplatedEmail(
  165.             $email,
  166.             'Connexion à votre espace Bambboo',
  167.             'emails/magic_link.html.twig',
  168.             ['firstname' => $firstname'magic_link_url' => $magicLinkUrl]
  169.           );
  170.         } catch (\Throwable $e) {
  171.           $this->logger->error('requestAccess: envoi Magic Link échoué', ['email' => $email'exception' => $e->getMessage()]);
  172.           $this->addFlash('error''L\'envoi du lien de connexion a échoué. Veuillez réessayer dans quelques minutes ou contacter le support.');
  173.           return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
  174.         }
  175.         return $this->redirectToRoute('home', [
  176.           'email' => $email,
  177.           'magic_link_sent' => '1',
  178.           'magic_link_type' => 'connexion',
  179.         ]);
  180.       }
  181.       $client $this->clientRepository->findClientByEmailDomain($email);
  182.       $eligible = ($client !== null);
  183.       if ($client !== null) {
  184.         $slug $client->getSlug();
  185.         $tokenCooptor $client->getTokenCooptor();
  186.         $signinUrl $this->generateUrl('client.signin', ['slug' => $slug'token' => $tokenCooptor], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
  187.         $session->set('request_access_signin_url'$signinUrl);
  188.         $session->set('request_access_email'$email);
  189.       }
  190.     } elseif ($form->isSubmitted() && !$form->isValid()) {
  191.       $this->addFlash('error''Veuillez saisir une adresse email professionnelle valide.');
  192.     }
  193.     return $this->redirectToRoute('home', [
  194.       'show' => 'demander-mon-acces',
  195.       'eligible' => $eligible === true '1' : ($eligible === false '0' null),
  196.     ]);
  197.   }
  198.   /**
  199.    * Renvoie l'email de lien d'inscription (après succès "Demander mon accès").
  200.    */
  201.   public function requestAccessResend(Request $requestSessionInterface $session): Response
  202.   {
  203.     if ($this->getUser()) {
  204.       return $this->redirectToRoute('suggestion.index');
  205.     }
  206.     $token $request->request->get('_csrf_token''');
  207.     if (!$this->isCsrfTokenValid('request_access_resend'$token)) {
  208.       $this->addFlash('error''Token de sécurité invalide.');
  209.       return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
  210.     }
  211.     $email $session->get('request_access_email');
  212.     $signinUrl $session->get('request_access_signin_url');
  213.     if ($email === null || $signinUrl === null) {
  214.       $this->addFlash('warning''Session expirée. Veuillez vérifier à nouveau votre éligibilité.');
  215.       return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
  216.     }
  217.     $user $this->userRepository->findOneByLoginEmail($email);
  218.     $firstname $user !== null trim((string) $user->getFirstname()) : '';
  219.     try {
  220.       $this->sendRequestAccessEmail($email$firstname$signinUrl);
  221.       $this->addFlash('success''L\'email a été renvoyé.');
  222.     } catch (TransportExceptionInterface $e) {
  223.       $this->logger->error('Request access resend email failed', ['email' => $email'exception' => $e->getMessage()]);
  224.       $this->addFlash('error''Impossible de renvoyer l\'email. Veuillez réessayer plus tard.');
  225.     }
  226.     return $this->redirectToRoute('home', ['show' => 'demander-mon-acces']);
  227.   }
  228.   /**
  229.    * Demande d'envoi d'un Magic Link depuis la page d'accueil (POST).
  230.    * Si le compte existe : envoi du lien de connexion.
  231.    * Si le compte n'existe pas et que le domaine est reconnu : création du contexte pending signup et envoi du lien.
  232.    */
  233.   public function requestMagicLink(Request $request): Response
  234.   {
  235.     if ($this->getUser()) {
  236.       return $this->redirectToRoute('suggestion.index');
  237.     }
  238.     $email trim((string) $request->request->get('email'''));
  239.     if ($email === '') {
  240.       $this->addFlash('error''Veuillez saisir votre adresse email.');
  241.       return $this->redirectToRoute('home');
  242.     }
  243.     if (!$this->isCsrfTokenValid('request_magic_link'$request->request->get('_csrf_token'''))) {
  244.       $this->addFlash('error''Token de sécurité invalide.');
  245.       return $this->redirectToRoute('home');
  246.     }
  247.     $email strtolower($email);
  248.     $user $this->userRepository->findOneByLoginEmail($email);
  249.     if ($user !== null) {
  250.       if (!$user->isIsActive()) {
  251.         $this->authMonitoring->record('magic_link_inactive_account'$emailnull$user->getId(), $request->getClientIp());
  252.         $this->addFlash('error''Ce compte est inactif. Contactez votre responsable RH ou le support Bambboo.');
  253.         return $this->redirectToRoute('home', ['email' => $email]);
  254.       }
  255.       try {
  256.         $magicToken $this->magicLinkService->generateForExistingUser($user);
  257.         $magicLinkUrl $this->generateUrl('app_magic_link_verify', [], UrlGeneratorInterface::ABSOLUTE_URL) . '#' $magicToken;
  258.         $firstname trim((string) $user->getFirstname());
  259.         $this->emailSenderService->sendTemplatedEmail(
  260.           $email,
  261.           'Connexion à votre espace Bambboo',
  262.           'emails/magic_link.html.twig',
  263.           ['firstname' => $firstname'magic_link_url' => $magicLinkUrl]
  264.         );
  265.         $this->authMonitoring->record('magic_link_sent_login'$emailnull$user->getId(), $request->getClientIp());
  266.       } catch (\Throwable $e) {
  267.         $this->authMonitoring->record('magic_link_send_failed'$emailnull$user->getId(), $request->getClientIp(), ['error' => $e->getMessage()]);
  268.         $this->addFlash('error''L\'envoi du lien de connexion a échoué. Veuillez réessayer dans quelques minutes ou contacter le support.');
  269.         return $this->redirectToRoute('home', ['email' => $email]);
  270.       }
  271.       return $this->redirectToRoute('home', ['email' => $email'magic_link_sent' => '1''magic_link_type' => 'connexion']);
  272.     }
  273.     $client $this->clientRepository->findClientByEmailDomain($email);
  274.     if ($client !== null) {
  275.       try {
  276.         $magicToken $this->magicLinkService->generateForPendingSignup($email$client'ROLE_COOPTOR');
  277.         $magicLinkUrl $this->generateUrl('app_magic_link_verify', [], UrlGeneratorInterface::ABSOLUTE_URL) . '#' $magicToken;
  278.         $firstname '';
  279.         $this->emailSenderService->sendTemplatedEmail(
  280.           $email,
  281.           'Vérifiez votre email pour activer votre compte Bambboo',
  282.           'emails/signup_verify_email.html.twig',
  283.           ['firstname' => $firstname'magic_link_url' => $magicLinkUrl]
  284.         );
  285.         $this->authMonitoring->record('magic_link_sent_signup'$email$client->getId(), null$request->getClientIp(), ['client_name' => $client->getName()]);
  286.       } catch (\Throwable $e) {
  287.         $this->authMonitoring->record('magic_link_send_failed'$email$client->getId(), null$request->getClientIp(), ['error' => $e->getMessage()]);
  288.         $this->addFlash('error''L\'envoi du lien d\'activation a échoué. Veuillez réessayer dans quelques minutes ou contacter le support.');
  289.         return $this->redirectToRoute('home', ['email' => $email]);
  290.       }
  291.       return $this->redirectToRoute('home', ['email' => $email'magic_link_sent' => '1''magic_link_type' => 'inscription']);
  292.     }
  293.     $this->authMonitoring->record('domain_not_partner'$emailnullnull$request->getClientIp());
  294.     return $this->redirectToRoute('home', ['email' => $email'login_error' => 'domain_not_partner']);
  295.   }
  296.   /**
  297.    * Envoie l'email contenant le lien d'inscription (demander mon accès).
  298.    *
  299.    * @param string $email Destinataire
  300.    * @param string $firstname Prénom pour la salutation
  301.    * @param string $signinUrl URL absolue du formulaire d'inscription client
  302.    */
  303.   private function sendRequestAccessEmail(string $emailstring $firstnamestring $signinUrl): void
  304.   {
  305.     $this->emailSenderService->sendTemplatedEmail(
  306.       $email,
  307.       'Accéder à mon espace Bambboo',
  308.       'emails/request_access_link.html.twig',
  309.       [
  310.         'firstname' => $firstname,
  311.         'signin_url' => $signinUrl,
  312.       ]
  313.     );
  314.   }
  315.   /**
  316.    * Extrait un prénom pour la salutation à partir de la partie locale de l'email.
  317.    */
  318.   private function extractFirstnameFromEmail(string $email): string
  319.   {
  320.     $pos strpos($email'@');
  321.     if ($pos === false || $pos === 0) {
  322.       return '';
  323.     }
  324.     $local substr($email0$pos);
  325.     $local preg_replace('/[^a-zA-ZÀ-ÿ]/'''$local);
  326.     return $local !== '' ucfirst(strtolower($local)) : '';
  327.   }
  328.   /**
  329.    * Page d'inscription depuis un lien client (slug/token)
  330.    * 
  331.    * @Route("/{slug}/{token}", name="client.signin")
  332.    */
  333.   public function signin(
  334.     string $slug,
  335.     string $token,
  336.     ClientRepository $clientRepository,
  337.     AuthenticationUtils $authenticationUtils,
  338.     SessionInterface $session,
  339.     Request $request,
  340.     UserRepository $userRepository,
  341.     RecaptchaService $recaptchaService,
  342.     MobileDetectorInterface $mobileDetector
  343.   ): Response {
  344.     // Si l'utilisateur est déjà authentifié, on exécute directement la logique post-login
  345.     if ($this->getUser()) {
  346.       return $this->forward(PostLoginController::class . '::handlePostLogin');
  347.     }
  348.     $session->remove('linkedin_cookies');
  349.     $client $clientRepository->findOneBy(['slug' => $slug]);
  350.     $session->set('origin'$request->getRequestUri());
  351.     if (!$client) {
  352.       return $this->redirectToRoute('home');
  353.     }
  354.     $canSignIn 1;
  355.     $role = [];
  356.     if ($token === $client->getTokenRecruiter()) {
  357.       $role = ["ROLE_RECRUITER"];
  358.       $maxUser $client->getMaxRecruiter();
  359.       if ($maxUser) {
  360.         $coworkers count($userRepository->getCoworkers($client));
  361.         if ($coworkers >= $maxUser) {
  362.           $canSignIn 0;
  363.         }
  364.       }
  365.     } elseif ($token === $client->getTokenCooptor()) {
  366.       $role = ["ROLE_COOPTOR"];
  367.       $maxUser $client->getMaxCooptor();
  368.       if ($maxUser) {
  369.         $coworkers count($userRepository->getCoworkers($client));
  370.         if ($coworkers >= $maxUser) {
  371.           $canSignIn 0;
  372.         }
  373.       }
  374.     } else {
  375.       return $this->redirectToRoute('home');
  376.     }
  377.     $session->set('client_id'$client->getId());
  378.     $session->set('role'$role);
  379.     $error $authenticationUtils->getLastAuthenticationError();
  380.     $isMobile $mobileDetector->isMobile();
  381.     // Créer le formulaire d'inscription
  382.     $user = new User();
  383.     $form $this->createForm(SigninRegistrationFormType::class, $user);
  384.     $form->handleRequest($request);
  385.     if ($form->isSubmitted()) {
  386.       $submittedEmail null;
  387.       try {
  388.         $submittedEmail $form->get('email')->getData();
  389.       } catch (\Throwable $e) {
  390.         $submittedEmail null;
  391.       }
  392.       $this->logger->info('SIGNUP: formulaire soumis', [
  393.         'route' => 'client.signin',
  394.         'method' => $request->getMethod(),
  395.         'slug' => $slug,
  396.         'is_mobile' => $isMobile,
  397.         'email' => $submittedEmail,
  398.       ]);
  399.     }
  400.     // Si l'utilisateur existe déjà : envoyer un Magic Link de connexion
  401.     if ($form->isSubmitted()) {
  402.       $email $form->get('email')->getData();
  403.       $email is_string($email) ? strtolower(trim($email)) : '';
  404.       if ($email !== '') {
  405.         $existingUser $userRepository->findOneByLoginEmail($email);
  406.         if ($existingUser) {
  407.           $this->logger->info('SIGNUP: email déjà existant, envoi Magic Link', [
  408.             'email' => $email,
  409.             'existing_user_id' => $existingUser->getId(),
  410.             'is_mobile' => $isMobile,
  411.           ]);
  412.           if (!$existingUser->isIsActive()) {
  413.             $recaptchaSiteKey $this->getRecaptchaSiteKey();
  414.             return $this->render('client/signin.html.twig', [
  415.               'client' => $client,
  416.               'error' => $error,
  417.               'canSignIn' => $canSignIn,
  418.               'form' => $form->createView(),
  419.               'recaptcha_site_key' => $recaptchaSiteKey,
  420.               'magic_link_sent' => false,
  421.             ]);
  422.           }
  423.           try {
  424.             $magicToken $this->magicLinkService->generateForExistingUser($existingUser);
  425.             $magicLinkUrl $this->generateUrl('app_magic_link_verify', [], UrlGeneratorInterface::ABSOLUTE_URL) . '#' $magicToken;
  426.             $firstname trim((string) $existingUser->getFirstname());
  427.             $this->emailSenderService->sendTemplatedEmail(
  428.               $email,
  429.               'Connexion à votre espace Bambboo',
  430.               'emails/magic_link.html.twig',
  431.               ['firstname' => $firstname'magic_link_url' => $magicLinkUrl]
  432.             );
  433.           } catch (\Throwable $e) {
  434.             $this->logger->error('SIGNUP: envoi Magic Link échoué', ['email' => $email'exception' => $e->getMessage()]);
  435.           }
  436.           $recaptchaSiteKey $this->getRecaptchaSiteKey();
  437.           return $this->render('client/signin.html.twig', [
  438.             'client' => $client,
  439.             'error' => $error,
  440.             'canSignIn' => $canSignIn,
  441.             'form' => $form->createView(),
  442.             'recaptcha_site_key' => $recaptchaSiteKey,
  443.             'magic_link_sent' => true,
  444.             'magic_link_email' => $email,
  445.             'magic_link_type' => 'connexion',
  446.           ]);
  447.         }
  448.       }
  449.     }
  450.     if ($form->isSubmitted() && !$form->isValid()) {
  451.       $errors = [];
  452.       foreach ($form->getErrors(true) as $errorItem) {
  453.         $origin $errorItem->getOrigin();
  454.         $errors[] = [
  455.           'field' => $origin $origin->getName() : null,
  456.           'message' => $errorItem->getMessage(),
  457.         ];
  458.       }
  459.       $this->logger->warning('SIGNUP: formulaire invalide', [
  460.         'is_mobile' => $isMobile,
  461.         'email' => $form->has('email') ? $form->get('email')->getData() : null,
  462.         'errors' => $errors,
  463.       ]);
  464.     }
  465.     if ($form->isSubmitted() && $form->isValid()) {
  466.       // Validation reCAPTCHA Enterprise uniquement si l'utilisateur n'est pas connecté
  467.       if (!$this->getUser()) {
  468.         $recaptchaToken $form->get('recaptcha_token')->getData();
  469.         $recaptchaResult $recaptchaService->verify(
  470.           $recaptchaToken ?? '',
  471.           $request->getClientIp(),
  472.           0.3 // Score minimum requis (abaissé pour être plus souple)
  473.         );
  474.         // En mode souple, on ne bloque jamais l'inscription même si le reCAPTCHA échoue
  475.         if (!$recaptchaResult['success']) {
  476.           // Log pour monitoring
  477.         }
  478.       }
  479.       $email $form->get('email')->getData();
  480.       $email is_string($email) ? strtolower(trim($email)) : '';
  481.       $roleValue $role[0] ?? 'ROLE_COOPTOR';
  482.       try {
  483.         $magicToken $this->magicLinkService->generateForPendingSignup($email$client$roleValue);
  484.         $magicLinkUrl $this->generateUrl('app_magic_link_verify', [], UrlGeneratorInterface::ABSOLUTE_URL) . '#' $magicToken;
  485.         $firstname '';
  486.         $this->emailSenderService->sendTemplatedEmail(
  487.           $email,
  488.           'Vérifiez votre email pour activer votre compte Bambboo',
  489.           'emails/signup_verify_email.html.twig',
  490.           ['firstname' => $firstname'magic_link_url' => $magicLinkUrl]
  491.         );
  492.       } catch (\Throwable $e) {
  493.         $this->logger->error('SIGNUP: envoi Magic Link pending signup échoué', ['email' => $email'exception' => $e->getMessage()]);
  494.         $recaptchaSiteKey $this->getRecaptchaSiteKey();
  495.         return $this->render('client/signin.html.twig', [
  496.           'client' => $client,
  497.           'error' => $error,
  498.           'canSignIn' => $canSignIn,
  499.           'form' => $form->createView(),
  500.           'recaptcha_site_key' => $recaptchaSiteKey,
  501.           'magic_link_sent' => false,
  502.         ]);
  503.       }
  504.       $recaptchaSiteKey $this->getRecaptchaSiteKey();
  505.       return $this->render('client/signin.html.twig', [
  506.         'client' => $client,
  507.         'error' => $error,
  508.         'canSignIn' => $canSignIn,
  509.         'form' => $form->createView(),
  510.         'recaptcha_site_key' => $recaptchaSiteKey,
  511.         'magic_link_sent' => true,
  512.         'magic_link_email' => $email,
  513.         'magic_link_type' => 'inscription',
  514.       ]);
  515.     }
  516.     $recaptchaSiteKey $this->getRecaptchaSiteKey();
  517.     return $this->render('client/signin.html.twig', [
  518.       'client' => $client,
  519.       'error' => $error,
  520.       'canSignIn' => $canSignIn,
  521.       'form' => $form->createView(),
  522.       'recaptcha_site_key' => $recaptchaSiteKey,
  523.       'magic_link_sent' => false,
  524.     ]);
  525.   }
  526.   /**
  527.    * Retourne la clé reCAPTCHA Enterprise si configurée.
  528.    */
  529.   private function getRecaptchaSiteKey(): string
  530.   {
  531.     if ($this->getUser()) {
  532.       return '';
  533.     }
  534.     try {
  535.       return (string) ($this->getParameter('app.recaptcha_enterprise_site_key') ?? '');
  536.     } catch (\Exception $e) {
  537.       return '';
  538.     }
  539.   }
  540.   /**
  541.    * @Route("/logout", name="app_logout")
  542.    */
  543.   public function logout(): void
  544.   {
  545.     // Ce code ne sera jamais exécuté, Symfony intercepte cette route pour gérer la déconnexion
  546.     throw new \LogicException('Logout route');
  547.   }
  548. }