Browse Source

Adds admin password to new structure trial

Implements the ability to set and validate an admin password when creating a new structure during the artist premium trial process.

The changes include:
- Adding a password field to the NewStructureArtistPremiumTrialRequest API resource, with validation to ensure it meets complexity requirements.
- Updating the OrganizationFactory to include a method for setting the admin account password, ensuring password complexity and updating the associated Person entity.
- Modifying the ShopService to call the new password setting method during the new structure creation process.
- Adding the admin username and login URL to the confirmation email sent to the representative.
- Updating environment variables to include the ADMIN_BASE_URL for different environments.
Olivier Massot 5 months ago
parent
commit
6bb7b05e6b

+ 4 - 0
.env

@@ -32,6 +32,10 @@ MERCURE_JWT_SECRET=xxx
 CORS_ALLOW_ORIGIN=^https?://(localhost|127\.0\.0\.1)(:[0-9]+)$
 ###< nelmio/cors-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://local.admin.opentalent.fr
+###
+
 ###> url v2 ###
 PUBLIC_API_BASE_URL=https://local.ap2i.opentalent.fr
 ###

+ 1 - 2
config/services.yaml

@@ -25,6 +25,7 @@ services:
             $publicLegacyBaseUrl: '%env(PUBLIC_API_LEG_BASE_URL)%'
             $baseUrl: '%env(API_BASE_URL)%'
             $publicBaseUrl: '%env(PUBLIC_API_BASE_URL)%'
+            $adminBaseUrl: '%env(ADMIN_BASE_URL)%'
             $softwareWebsiteUrl: '%env(SOFTWARE_WEBSITE_URL)%'
             $opentalentMailReport: 'mail.report@opentalent.fr'
             $fileStorageDir: '%kernel.project_dir%/var/files/storage'
@@ -134,5 +135,3 @@ services:
         arguments:
             - '@doctrine.orm.command.entity_manager_provider'
         tags: ['console.command']
-
-

+ 4 - 0
env/.env.docker

@@ -8,6 +8,10 @@ APP_SECRET=211cede3dc4b162da3ec2fbdcd905070
 CORS_ALLOW_ORIGIN=^https?:\/\/(localhost|127\.0\.0\.1|(local.(admin|app|app|frames|agenda|maestro|logiciels).opentalent.fr))(:[0-9]+)?$
 ###< nelmio/cors-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://local.admin.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=http://nginx/
 PUBLIC_API_LEG_BASE_URL=https://local.api.opentalent.fr

+ 4 - 0
env/.env.prod

@@ -1,6 +1,10 @@
 ###> doctrine/doctrine-bundle ###
 APP_ENV=prod
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.opentalent.fr/api

+ 4 - 0
env/.env.staging

@@ -8,6 +8,10 @@ MERCURE_JWT_SECRET='TXsLVKnU4Ew4oyH4qcQO81CuSOVTbj58W42fDTIzIZPwpPCaGu2EvIL3DbtD
 CORS_ALLOW_ORIGIN=^$
 ###< nelmio/cors-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://none
+###
+
 ####> api v1 ###
 API_LEG_BASE_URL=https://none
 PUBLIC_API_LEG_BASE_URL=https://none

+ 4 - 0
env/.env.test

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test.opentalent.fr/api

+ 4 - 0
env/.env.test1

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test1.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test1.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test1.opentalent.fr/api

+ 4 - 0
env/.env.test2

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test2.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test2.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test2.opentalent.fr/api

+ 4 - 0
env/.env.test3

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test3.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test3.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test3.opentalent.fr/api

+ 4 - 0
env/.env.test4

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test4.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test4.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test4.opentalent.fr/api

+ 4 - 0
env/.env.test5

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test5.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test5.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test5.opentalent.fr/api

+ 4 - 0
env/.env.test6

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test6.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test6.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test6.opentalent.fr/api

+ 4 - 0
env/.env.test7

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test7.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test7.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test7.opentalent.fr/api

+ 4 - 0
env/.env.test8

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test8.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test8.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test8.opentalent.fr/api

+ 4 - 0
env/.env.test9

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test9.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test9.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test9.opentalent.fr/api

+ 25 - 0
src/ApiResources/Shop/NewStructureArtistPremiumTrialRequest.php

@@ -113,6 +113,19 @@ class NewStructureArtistPremiumTrialRequest implements ShopRequestData
 
     private bool $newsletterSubscription = false;
 
+    /**
+     * Password of the admin account of the newly created structure.
+
+     * Must have at least 8 characters, including at least one uppercase letter,
+     * one lowercase letter, one digit, and one special character.
+     */
+    #[Assert\NotBlank]
+    #[Assert\Regex(
+        pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).{8,}$/',
+        message: 'Password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one digit, and one special character.'
+    )]
+    private string $password;
+
     public function getId(): int
     {
         return $this->id;
@@ -340,4 +353,16 @@ class NewStructureArtistPremiumTrialRequest implements ShopRequestData
 
         return $this;
     }
+
+    public function getPassword(): string
+    {
+        return $this->password;
+    }
+
+    public function setPassword(string $password): self
+    {
+        $this->password = $password;
+
+        return $this;
+    }
 }

+ 2 - 0
src/Service/Mailer/Builder/Shop/NewStructureArtistPremium/ConfirmationToRepresentativeBuilder.php

@@ -42,6 +42,8 @@ class ConfirmationToRepresentativeBuilder extends AbstractBuilder implements Bui
             'trialRequest' => $mailerModel->getTrialRequest(),
             'accountCreationUrl' => $mailerModel->getAccountCreationUrl(),
             'faqUrl' => $mailerModel->getFaqUrl(),
+            'adminUsername' => $mailerModel->getAdminUsername(),
+            'adminLoginUrl' => $mailerModel->getAdminLoginUrl(),
         ];
 
         $content = $this->render('shop/new-structure-artist-premium-trial-account-creation', $context);

+ 26 - 0
src/Service/Mailer/Model/Shop/NewStructureArtistPremium/ConfirmationToRepresentativeModel.php

@@ -17,6 +17,8 @@ class ConfirmationToRepresentativeModel extends AbstractMailerModel implements M
     private NewStructureArtistPremiumTrialRequest $trialRequest;
     private string $accountCreationUrl;
     private string $faqUrl;
+    private string $adminUsername;
+    private string $adminLoginUrl;
 
     public function getTrialRequest(): NewStructureArtistPremiumTrialRequest
     {
@@ -53,4 +55,28 @@ class ConfirmationToRepresentativeModel extends AbstractMailerModel implements M
 
         return $this;
     }
+
+    public function getAdminUsername(): string
+    {
+        return $this->adminUsername;
+    }
+
+    public function setAdminUsername(string $adminUsername): self
+    {
+        $this->adminUsername = $adminUsername;
+
+        return $this;
+    }
+
+    public function getAdminLoginUrl(): string
+    {
+        return $this->adminLoginUrl;
+    }
+
+    public function setAdminLoginUrl(string $adminLoginUrl): self
+    {
+        $this->adminLoginUrl = $adminLoginUrl;
+
+        return $this;
+    }
 }

+ 46 - 0
src/Service/Organization/OrganizationFactory.php

@@ -60,6 +60,13 @@ class OrganizationFactory
 
     protected PhoneNumberUtil $phoneNumberUtil;
 
+    /**
+     * Regex pattern for password validation.
+     * Requires at least 8 characters, including at least one uppercase letter,
+     * one lowercase letter, one digit, and one special character.
+     */
+    private const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).{8,}$/';
+
     public function __construct(
         private readonly SubdomainService $subdomainService,
         private readonly OrganizationRepository $organizationRepository,
@@ -74,6 +81,7 @@ class OrganizationFactory
         private readonly ApiLegacyRequestService $apiLegacyRequestService,
         private readonly FunctionTypeRepository $functionTypeRepository,
         private readonly FileManager $fileManager,
+        private readonly \App\Repository\Access\AccessRepository $accessRepository,
     ) {
         $this->phoneNumberUtil = PhoneNumberUtil::getInstance();
     }
@@ -931,4 +939,42 @@ class OrganizationFactory
     {
         $this->dolibarrApiService->switchSocietyToProspect($organizationId);
     }
+
+    /**
+     * Sets the password for the admin account of an organization.
+     *
+     * The admin account is identified by the adminaccess property set to true.
+     * The password must meet the complexity requirements: at least 8 characters,
+     * including at least one uppercase letter, one lowercase letter, one digit,
+     * and one special character.
+     *
+     * @param Organization $organization The organization whose admin account password will be set
+     * @param string $password The plain password to set
+     *
+     * @throws \RuntimeException If no admin account is found or if the password doesn't meet the requirements
+     */
+    public function setAdminAccountPassword(Organization $organization, string $password): void
+    {
+        // Validate password complexity
+        if (!preg_match(self::PASSWORD_PATTERN, $password)) {
+            throw new \RuntimeException('Password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one digit, and one special character.');
+        }
+
+        // Find the admin access using the repository
+        $adminAccess = $this->accessRepository->findAdminAccess($organization);
+
+        if (!$adminAccess) {
+            throw new \RuntimeException('No admin account found for this organization.');
+        }
+
+        // Set the password on the Person entity
+        $person = $adminAccess->getPerson();
+        $person->setPassword($password);
+
+        // Persist the changes
+        $this->entityManager->persist($person);
+        $this->entityManager->flush();
+
+        $this->logger->info('Admin account password set for organization: ' . $organization->getId());
+    }
 }

+ 16 - 2
src/Service/Shop/ShopService.php

@@ -51,13 +51,14 @@ class ShopService
         private EntityManagerInterface $entityManager,
         private Mailer $mailer,
         private string $publicBaseUrl,
+        private string $publicAdminBaseUrl,
         private OrganizationFactory $organizationFactory,
         private SerializerInterface $serializer,
         private LoggerInterface $logger,
         private MessageBusInterface $messageBus,
         private Trial $trial,
         private string $faqUrl,
-        private readonly string $softwareWebsiteUrl
+        private readonly string $softwareWebsiteUrl,
     ) {
         $this->phoneNumberUtil = PhoneNumberUtil::getInstance();
     }
@@ -200,6 +201,9 @@ class ShopService
 
         $organization = $this->createOrganization($trialRequest);
 
+        // Set the admin account password
+        $this->organizationFactory->setAdminAccountPassword($organization, $trialRequest->getPassword());
+
         // Start the artist premium trial
         $this->trial->startArtistPremiumTrialForNewStructure($organization, $trialRequest);
 
@@ -312,14 +316,24 @@ class ShopService
      *
      * @throws TransportExceptionInterface
      */
-    protected function sendConfirmationMailToRepresentative(NewStructureArtistPremiumTrialRequest $trialRequest): void
+    protected function sendConfirmationMailToRepresentative(
+        NewStructureArtistPremiumTrialRequest $trialRequest,
+    ): void
     {
+        // Create the admin username
+        $adminUsername = 'admin' . $trialRequest->getStructureIdentifier();
+
+        // Create the admin login URL
+        $adminLoginUrl = UrlBuilder::concat($this->publicAdminBaseUrl, ['#/login/']);
+
         // Create the email model
         $model = new ConfirmationToRepresentativeModel();
         $model
             ->setTrialRequest($trialRequest)
             ->setAccountCreationUrl(UrlBuilder::concat($this->publicBaseUrl, ['/account/create']))
             ->setFaqUrl($this->faqUrl)
+            ->setAdminUsername($adminUsername)
+            ->setAdminLoginUrl($adminLoginUrl)
             ->setSenderId(AccessIdsEnum::ADMIN_2IOPENSERVICE->value);
 
         // Send the email to the representative

+ 5 - 5
templates/emails/shop/new-structure-artist-premium-trial-account-creation.html.twig

@@ -7,16 +7,16 @@
 
     <p>Votre demande d'essai pour Opentalent Artist Premium a été validée.</p>
 
-    <p>Pour finaliser votre inscription et accéder à votre espace, veuillez cliquer sur le lien ci-dessous :</p>
+    <p>Votre compte administrateur a été créé avec l'identifiant <strong>{{ adminUsername }}</strong>.</p>
+
+    <p>Pour accéder à votre espace, veuillez cliquer sur le lien ci-dessous :</p>
 
     <p style="text-align: center; margin: 30px 0;">
-        <a href="{{ accountCreationUrl }}" style="background-color: #4CAF50; color: white; padding: 15px 32px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; margin: 4px 2px; cursor: pointer; border-radius: 10px;">
-            Créer mon compte et accéder au logiciel
+        <a href="{{ adminLoginUrl }}" style="background-color: #4CAF50; color: white; padding: 15px 32px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; margin: 4px 2px; cursor: pointer; border-radius: 10px;">
+            Accéder au logiciel
         </a>
     </p>
 
-    <p>Sur cette page, vous pourrez définir votre identifiant et votre mot de passe. Une fois ces informations renseignées, vous serez automatiquement connecté à votre compte.</p>
-
     <p>Besoin d'aide ? Consultez notre FAQ complète pour découvrir toutes les fonctionnalités du logiciel et trouver les réponses à vos questions :</p>
 
     <p style="text-align: center; margin: 30px 0;">

+ 66 - 0
tests/Unit/Service/Organization/OrganizationFactoryTest.php

@@ -185,6 +185,7 @@ class OrganizationFactoryTest extends TestCase
     private readonly MockObject|ApiLegacyRequestService $apiLegacyRequestService;
     private readonly MockObject|PhoneNumberUtil $phoneNumberUtil;
     private readonly MockObject|FunctionTypeRepository $functionTypeRepository;
+    private readonly MockObject|\App\Repository\Access\AccessRepository $accessRepository;
 
     public function setUp(): void
     {
@@ -203,6 +204,7 @@ class OrganizationFactoryTest extends TestCase
         $this->phoneNumberUtil = $this->getMockBuilder(PhoneNumberUtil::class)->disableOriginalConstructor()->getMock();
         $this->functionTypeRepository = $this->getMockBuilder(FunctionTypeRepository::class)->disableOriginalConstructor()->getMock();
         $this->fileManager = $this->getMockBuilder(FileManager::class)->disableOriginalConstructor()->getMock();
+        $this->accessRepository = $this->getMockBuilder(\App\Repository\Access\AccessRepository::class)->disableOriginalConstructor()->getMock();
     }
 
     public function tearDown(): void
@@ -229,6 +231,7 @@ class OrganizationFactoryTest extends TestCase
                     $this->apiLegacyRequestService,
                     $this->functionTypeRepository,
                     $this->fileManager,
+                    $this->accessRepository,
                 ])
             ->setMethodsExcept(['setLoggerInterface', 'setPhoneNumberUtil', $methodName])
             ->getMock();
@@ -2190,4 +2193,67 @@ class OrganizationFactoryTest extends TestCase
 
         $organizationFactory->switchDolibarrSocietyToProspect(123);
     }
+
+    public function testSetAdminAccountPassword(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('setAdminAccountPassword');
+
+        // Create mock organization
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        // Create mock access with adminAccess=true
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        // Create mock person
+        $person = $this->getMockBuilder(Person::class)->getMock();
+
+        // Set up the access to return the person
+        $access->method('getPerson')->willReturn($person);
+
+        // Set up the AccessRepository to return the access when findAdminAccess is called
+        $this->accessRepository->method('findAdminAccess')->with($organization)->willReturn($access);
+
+        // Expect the person's setPassword method to be called with the password
+        $person->expects(self::once())->method('setPassword')->with('Password123!');
+
+        // Expect the EntityManager's persist and flush methods to be called
+        $this->entityManager->expects(self::once())->method('persist')->with($person);
+        $this->entityManager->expects(self::once())->method('flush');
+
+        // Call the method with a valid password
+        $organizationFactory->setAdminAccountPassword($organization, 'Password123!');
+    }
+
+    public function testSetAdminAccountPasswordInvalidPassword(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('setAdminAccountPassword');
+
+        // Create mock organization
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        // Expect an exception to be thrown for an invalid password
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one digit, and one special character.');
+
+        // Call the method with an invalid password
+        $organizationFactory->setAdminAccountPassword($organization, 'password');
+    }
+
+    public function testSetAdminAccountPasswordNoAdminAccount(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('setAdminAccountPassword');
+
+        // Create mock organization
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        // Set up the AccessRepository to return null when findAdminAccess is called
+        $this->accessRepository->method('findAdminAccess')->with($organization)->willReturn(null);
+
+        // Expect an exception to be thrown when no admin account is found
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('No admin account found for this organization.');
+
+        // Call the method with a valid password
+        $organizationFactory->setAdminAccountPassword($organization, 'Password123!');
+    }
 }

+ 8 - 0
tests/Unit/Service/Shop/ShopServiceTest.php

@@ -346,6 +346,14 @@ class ShopServiceTest extends TestCase
             ->with($trialRequest)
             ->willReturn($organization);
 
+        // Add expectation for password setting
+        $trialRequest->method('getPassword')->willReturn('Password123!');
+
+        $this->organizationFactory
+            ->expects(self::once())
+            ->method('setAdminAccountPassword')
+            ->with($organization, 'Password123!');
+
         $this->trial
             ->expects(self::once())
             ->method('startArtistPremiumTrialForNewStructure')