diff --git a/.env b/.env index 625e7ee..0f00f8c 100644 --- a/.env +++ b/.env @@ -48,6 +48,8 @@ OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.key OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.key OAUTH_PASSPHRASE=8170ea18d2e3e05b5c7ae0672a754bf4 OAUTH_ENCRYPTION_KEY=f1b7c279f7992205a0df45e295d07066 +OAUTH_SSO_IDENTIFIER='sso-own-identifier' +OAUTH_SSO_IDENTIFIER_LOGIN='sso-own-identifier' ###< league/oauth2-server-bundle ### ###> nelmio/cors-bundle ### @@ -71,4 +73,5 @@ AWS_REGION=us-east-1 AWS_ENDPOINT=https://s3.amazonaws.com AWS_S3_PORTAL_URL=https://s3.amazonaws.com/portal ###< aws/aws-sdk-php-symfony ### -APP_URL='https://example.com' \ No newline at end of file +APP_URL='https://example.com' +APP_DOMAIN='example.com' \ No newline at end of file diff --git a/assets/controllers/base_controller.js b/assets/controllers/base_controller.js new file mode 100644 index 0000000..379f069 --- /dev/null +++ b/assets/controllers/base_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + async fetchAndRenderApplications(targetElement) { + try { + const response = await fetch('/application/data/all'); + const apps = await response.json(); + + targetElement.innerHTML = apps.map(app => ` +
+
+ + +
+
+ `).join(''); + + return apps; + } catch (error) { + targetElement.innerHTML = '
Erreur de chargement.
'; + console.error("App load error:", error); + } + } +} \ No newline at end of file diff --git a/assets/controllers/project_controller.js b/assets/controllers/project_controller.js index bcf2e12..6b8cc40 100644 --- a/assets/controllers/project_controller.js +++ b/assets/controllers/project_controller.js @@ -10,7 +10,7 @@ export default class extends Controller { orgId: Number, admin: Boolean } - static targets = ["modal", "appList", "nameInput", "formTitle"]; + static targets = ["modal", "appList", "nameInput", "formTitle", "timestampSelect", "deletionSelect"]; connect(){ if(this.listProjectValue){ this.table(); @@ -137,35 +137,44 @@ export default class extends Controller { async submitForm(event) { event.preventDefault(); - const formData = new FormData(event.target); + const form = event.target; + const formData = new FormData(form); // This automatically picks up the 'logo' file - const payload = { - organizationId: this.orgIdValue, - applications: formData.getAll('applications[]') - }; - - // Only include name if it wasn't disabled (new projects) - if (!this.nameInputTarget.disabled) { - payload.name = formData.get('name'); + // 1. Validate File Format + const logoFile = formData.get('logo'); + if (logoFile && logoFile.size > 0) { + const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg']; + if (!allowedTypes.includes(logoFile.type)) { + alert("Format invalide. Veuillez utiliser uniquement des fichiers PNG ou JPG."); + return; // Stop submission + } } + // 2. Prepare for Multipart sending + // Since we are using FormData, we don't need JSON.stringify or 'Content-Type': 'application/json' + // We add the extra fields to the formData object + formData.append('organizationId', this.orgIdValue); + const url = this.currentProjectId ? `/project/edit/${this.currentProjectId}/ajax` : `/project/new/ajax`; const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + // IMPORTANT: Do NOT set Content-Type header when sending FormData with files + // The browser will set 'multipart/form-data' and the boundary automatically + body: formData }); if (response.ok) { this.modal.hide(); - // Use Tabulator's setData() instead of reload() for better UX if possible location.reload(); } else { + const result = await response.json(); if (response.status === 409) { - alert("Un projet avec ce nom existe déjà. Veuillez choisir un nom différent."); + alert("Un projet avec ce nom existe déjà."); + } else { + alert(result.error || "Une erreur est survenue."); } } } @@ -188,6 +197,9 @@ export default class extends Controller { // 3. Set the name this.nameInputTarget.value = project.name; + console.log(project); + this.timestampSelectTarget.value = project.timestampPrecision; + this.deletionSelectTarget.value = project.deletionAllowed; // 4. Check the boxes // We look for all checkboxes inside our appList target diff --git a/assets/controllers/user_controller.js b/assets/controllers/user_controller.js index e7834b0..864edb6 100644 --- a/assets/controllers/user_controller.js +++ b/assets/controllers/user_controller.js @@ -10,9 +10,10 @@ import { trashIconForm } from "../js/global.js"; import { Modal } from "bootstrap"; +import base_controller from "./base_controller.js"; -export default class extends Controller { +export default class extends base_controller { static values = { rolesArray: Array, selectedRoleIds: Array, @@ -26,7 +27,7 @@ export default class extends Controller { orgId: Number } - static targets = ["select", "statusButton", "modal", "userSelect"]; + static targets = ["select", "statusButton", "modal", "userSelect", "appList", "emailInput", "phoneInput", "nameInput", "surnameInput"]; connect() { this.roleSelect(); @@ -1018,4 +1019,105 @@ export default class extends Controller { alert("Une erreur réseau est survenue."); } } + + async openNewUserModal() { + this.modal.show(); + // Call the shared logic and pass the target + await this.fetchAndRenderApplications(this.appListTarget); + } + + async submitNewUser(event) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + const ucSurname = formData.get('surname').toUpperCase(); + formData.set('surname', ucSurname); + + try { + const response = await fetch('/user/new/ajax', { // Adjust path if prefix is different + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + const result = await response.json(); + + if (response.ok) { + this.modal.hide(); + form.reset(); // Clear the form + location.reload(); + } else { + alert("Erreur: " + (result.error || "Une erreur est survenue lors de la création.")); + } + } catch (error) { + alert("Erreur réseau."); + } + } + + async openEditUserModal(event) { + const userId = event.currentTarget.dataset.id; + this.currentUserId = userId; + this.modal.show(); + + try { + // 1. Fetch all available apps using your shared base method + await this.fetchAndRenderApplications(this.appListTarget); + + // 2. Fetch specific user data WITH the orgId query parameter + // We use this.orgIdValue which is mapped to data-user-org-id-value + const response = await fetch(`/user/data/${userId}?orgId=${this.orgIdValue}`); + + if (!response.ok) throw new Error('Failed to fetch user data'); + + const user = await response.json(); + + // 3. Fill text inputs + this.emailInputTarget.value = user.email; + this.phoneInputTarget.value = user.phoneNumber || ''; + this.nameInputTarget.value = user.name; + this.surnameInputTarget.value = user.surname; + + // 4. Check the application boxes + const checkboxes = this.appListTarget.querySelectorAll('input[type="checkbox"]'); + + // Ensure we handle IDs as strings or numbers consistently + const activeAppIds = user.applicationIds.map(id => id.toString()); + + checkboxes.forEach(cb => { + cb.checked = activeAppIds.includes(cb.value.toString()); + }); + + } catch (error) { + console.error("Erreur lors du chargement des données utilisateur:", error); + alert("Impossible de charger les informations de l'utilisateur."); + } + } + + async submitEditUser(event) { + event.preventDefault(); + const formData = new FormData(event.target); + + // Force Uppercase on Surname as requested + formData.set('surname', formData.get('surname').toUpperCase()); + + try { + const response = await fetch(`/user/edit/${this.currentUserId}/ajax`, { + method: 'POST', + body: formData, + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + + if (response.ok) { + this.modal.hide(); + location.reload(); + } else { + const result = await response.json(); + alert(result.error || "Erreur lors de la mise à jour."); + } + } catch (error) { + alert("Erreur réseau."); + } + } } \ No newline at end of file diff --git a/assets/icons/bi/calendar.svg b/assets/icons/bi/calendar.svg new file mode 100644 index 0000000..01b8c56 --- /dev/null +++ b/assets/icons/bi/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/bi/input-cursor-text.svg b/assets/icons/bi/input-cursor-text.svg new file mode 100644 index 0000000..f2e716b --- /dev/null +++ b/assets/icons/bi/input-cursor-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/bi/trash3.svg b/assets/icons/bi/trash3.svg new file mode 100644 index 0000000..de8e5aa --- /dev/null +++ b/assets/icons/bi/trash3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/material-symbols/picture-in-picture-outline-rounded.svg b/assets/icons/material-symbols/picture-in-picture-outline-rounded.svg new file mode 100644 index 0000000..d89ffef --- /dev/null +++ b/assets/icons/material-symbols/picture-in-picture-outline-rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/js/global.js b/assets/js/global.js index 42d51d4..84a0c84 100644 --- a/assets/js/global.js +++ b/assets/js/global.js @@ -22,7 +22,7 @@ export const TABULATOR_FR_LANG = { }; export function eyeIconLink(url) { - return ` + return ` getContent(), true, 512, JSON_THROW_ON_ERROR); + $projet = new Projet(); + + $entity = $this->entityManager->getRepository(Entity::class)->findOneBy([ 'ssoId' => $data['orgId']]); + // Si l'entité n'existe pas, on la crée + if(!$entity){ + $this->createEntity($data['orgId'], $data['orgName']); + $entity = $this->entityManager->getRepository(Entity::class)->findOneBy([ 'ssoId' => $data['orgId']]); + } + $precision= $data['timestamp']; + $validPrecisions = array_map(fn($case) => $case->value, TimestampPrecision::cases()); + if (!in_array($precision, $validPrecisions, true)) { + return $this->json(['success' => false, 'message' => 'Précision d\'horodatage invalide.'], 400); + } + + try { + $timestampPrecision = TimestampPrecision::from($precision); + $projet->setTimestampPrecision($timestampPrecision); + $projet->setProjet($data['name']); + $projet->setEntityId($entity); + $projet->setBdd($data['bdd']); + $projet->setIsactive($data['isActive']); + $projet->setLogo($data['logo']); + $projet->setDeletionAllowed($data['deletion']); + $projet->setSsoId($data['id']); // c'est l'id du projet dans le portail, pas la bdd local + + $this->entityManager->persist($projet); + $this->entityManager->flush(); + return new JsonResponse(['message' => 'Project created successfully', 'project_id' => $projet->getId()], 201); + }catch ( \Exception $e){ + return new JsonResponse(['error' => 'Failed to create project: ' . $e->getMessage()], 500); + } + } +``` + +# Services +So, now we are getting into the thick of it. We are just COOK 🍗 ( a lot of bugs can come from here ). +We implement a new pretty service called SsoTokenHandler. This is used to get the token received from the portal request, and validate it. +It is validaded by doing a call back to the SSO and asking if the token is valid. ( we create a new token for the SSO so it handles M2M) +```php +httpClient->request('GET', $this->ssoUrl . '/api/validate-token', [ + 'headers' => ['Authorization' => 'Bearer ' . $accessToken] + ]); + + // If the SSO redirects, HttpClient might follow it to the login page. + // Let's see the first response code. + if ($response->getStatusCode() !== 200) { + // Log the content to see the HTML "Login" page that is causing the JSON error + // dump($response->getContent(false)); + throw new BadCredentialsException(); + } + } catch (\Exception $e) { + // This will show you if you are hitting a 302 Redirect + throw new BadCredentialsException('SSO returned invalid response: ' . $e->getMessage()); + } + + if (200 !== $response->getStatusCode()) { + throw new BadCredentialsException('Invalid SSO Token'); + } + + $data = $response->toArray(); + + // 2. Identify if it's a User or a Machine + $identifier = $data['email'] ?? 'SYSTEM_SSO_SERVER'; + + // 3. Return the badge with a "loader" closure + return new UserBadge($identifier, function($userIdentifier) use ($data) { + // If it's the SSO server calling, give it a specific role + if ($userIdentifier === 'SYSTEM_SSO_SERVER') { + return new InMemoryUser($userIdentifier, null, ['ROLE_API_INTERNAL']); + } + + // Otherwise, let the normal user provider handle it (for standard users) + // You might need to inject your actual UserProvider here if needed + return new InMemoryUser($userIdentifier, null, ['ROLE_USER']); + }); + } +} +``` + +# Important note 1 +we need to add the portal url to the .env and declare it as a parameter in services.yaml +```dotenv +SSO_URL='http://portail.solutions-easy.moi' +``` +```yaml +parameters: + sso_url: '%env(SSO_URL)%' + + App\Security\SsoTokenHandler: + arguments: + $ssoUrl: '%sso_url%' +``` +# Server side +On the server side, we need to create a new client, which will be himself, same as earlier, create it, and boom we are good. +The validate route is already created, so dw abt it. + +```cmd +php bin/console league:oauth2-server:create-client sso_internal_service --grant-type "client_credentials" +``` + +now, copy the identifier, and paste it in the .env file +please note that we have 2 client for the application because one is used for m2m and the other is used for the user, so implement both, the one ending with _LOGIN is the one for the user +```dotenv +OAUTH_SSO_IDENTIFIER='sso-own-identifier' +``` +and we are smart so what do we do? we add it to the services.yaml +```yaml +parameters: + oauth_sso_identifier: '%env(OAUTH_SSO_IDENTIFIER)%' + + App\Service\SSO\ProjectService: + arguments: + $appUrl: '%app_url%' + $clientIdentifier: '%oauth_sso_identifier%' +``` + +We should be good now ( I hope ). Open the portal, try your call and check if it works, if it doesn't, check the logs and debug, you are a dev for a reason, so use your brain and debug. +If it still doesn't work, start praying, because I have no idea what to do anymore, but it works on my side, so it should work on yours, if not, well, I don't know what to say. Good luck. +Jokes aside, bugs often come from security problem, if the client returns a 401 error, it can be for multiple reasons and not necessarily because of the token but maybe because of the token validation. +Another commun bug is mismatching of the data you send, so double check. GLHF ( you won't have fun, but good luck anyway ) + +. + +. + +. + +. + +. + +. + +. + +. + +. + +. + +. +If you have a problem and that you copy pasted everything, check client controller, I've put the route as /api/v2 just cause I felt like it 🤷 ENJOY diff --git a/docs/Client_Setup.md b/docs/Client_Setup.md index 51917c0..bb5d290 100644 --- a/docs/Client_Setup.md +++ b/docs/Client_Setup.md @@ -178,18 +178,54 @@ class SsoAuthenticator extends OAuth2Authenticator implements AuthenticationEntr **/ if (!$user) { $user = new User(); - $user->setEmail($sudalysSsoUser->getEmail()); - $user->setName($sudalysSsoUser->getName()); - $user->setSurname($sudalysSsoUser->getSurname()); - $user->setSsoId($sudalysSsoUser->getId()); + $user->setEmail($ssoData->getEmail()); + $user->setPrenom($ssoData->getName()); + $user->setNom($ssoData->getSurname()); + $user->setSsoId($ssoData->getId()); $this->em->persist($user); }else{ // On met a jour l'utilisateur - $user->setEmail($sudalysSsoUser->getEmail()); - $user->setName($sudalysSsoUser->getName()); - $user->setSurname($sudalysSsoUser->getSurname()); + $user->setEmail($ssoData->getEmail()); + $user->setPrenom($ssoData->getName()); + $user->setNom($ssoData->getSurname()); $this->em->persist($user); } + + //handle UOs links + $ssoArray = $ssoData->toArray(); + $uoData = $ssoArray['uos'] ?? []; + foreach ($uoData as $uo) { + $ssoOrgId = $uo['id']; + + $entity = $this->em->getRepository(Entity::class)->findOneBy(['ssoId' => $ssoOrgId]); + if (!$entity) { + $entity = new Entity(); + $entity->setSsoId($ssoOrgId); + $entity->setNom($uo['name']); + $this->em->persist($entity); + } + $role = $this->em->getRepository(Roles::class)->findOneBy(['name' => $uo['role']]); + + // Check if the user-organization link already exists + $existingLink = $this->em->getRepository(UsersOrganizations::class)->findOneBy([ + 'users' => $user, + 'organizations' => $entity + ]); + + if (!$existingLink) { + // Create a new link if it doesn't exist + $newLink = new UsersOrganizations(); + $newLink->setUsers($user); + $newLink->setOrganizations($entity); + $newLink->setRole($role); + $this->em->persist($newLink); + } else { + // Update the role if the link already exists + $existingLink->setRole($role); + $existingLink->setModifiedAt(new \DateTimeImmutable()); + $this->em->persist($existingLink); + } + } $this->em->flush(); return $user; } @@ -320,8 +356,10 @@ If there is a scope or grand error, delete the client do the following first php bin/console league:oauth2-server:delete-client ``` Identifier can be found in the database oauth2_client table -The recreate the client and enter the scopes and grant types after creating the client directly in the db +To recreate the client and enter the scopes and grant types after creating the client directly in the db ```text scopes = email profile openid grants = authorization_code ``` + + diff --git a/docs/Role_Hierarchy.md b/docs/Role_Hierarchy.md index 2dfb715..6bf5e54 100644 --- a/docs/Role_Hierarchy.md +++ b/docs/Role_Hierarchy.md @@ -31,4 +31,139 @@ Get Access to the following with the following authorisations: ## Organizations Roles Organizations roles are specific to individual Organizations. They include: - **Organization Admin**: Has full access to all organization features and settings. Can manage users of the organizations. -- **Organization User**: Has limited access to organization features and settings. Can view projects and applications, can manage own information \ No newline at end of file +- **Organization User**: Has limited access to organization features and settings. Can view projects and applications, can manage own information + + +# Set up +Like for the sso, we need to create roles in the system. create the following command and the create the roles. +``` php + +#[AsCommand( + name: 'app:create-role', + description: 'Creates a new role in the database' +)] +class CreateRoleCommand extends Command +{ + private EntityManagerInterface $entityManager; + + public function __construct(EntityManagerInterface $entityManager) + { + parent::__construct(); + $this->entityManager = $entityManager; + } + + protected function configure(): void + { + $this + ->addArgument('name', InputArgument::REQUIRED, 'The name of the role'); // role name required + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $roleName = trim($input->getArgument('name')); + $roleName = strtoupper($roleName); // Normalize to uppercase + + // Ensure not empty + if ($roleName === '') { + $output->writeln('The role name cannot be empty'); + return Command::FAILURE; + } + + // Check if role already exists + $existing = $this->entityManager->getRepository(Roles::class) + ->findOneBy(['name' => $roleName]); + + if ($existing) { + $output->writeln("Role '{$roleName}' already exists."); + return Command::SUCCESS; // not failure, just redundant + } + + // Create and persist new role + $role = new Roles(); + $role->setName($roleName); + + $this->entityManager->persist($role); + $this->entityManager->flush(); + + $output->writeln("Role '{$roleName}' created successfully!"); + + return Command::SUCCESS; + } +} +``` +```php +#[AsCommand( + name: 'app:delete-role', + description: 'Deletes a role from the database' +)] +class DeleteRoleCommand extends Command +{ + private EntityManagerInterface $entityManager; + + public function __construct(EntityManagerInterface $entityManager) + { + parent::__construct(); + $this->entityManager = $entityManager; + } + + protected function configure(): void + { + $this + ->addArgument('name', InputArgument::REQUIRED, 'The name of the role to delete'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $roleName = trim($input->getArgument('name')); + $roleName = strtoupper($roleName); // Normalize to uppercase + + if ($roleName === '') { + $output->writeln('The role name cannot be empty'); + return Command::FAILURE; + } + + // Find the role + $role = $this->entityManager->getRepository(Roles::class) + ->findOneBy(['name' => $roleName]); + + if (!$role) { + $output->writeln("Role '{$roleName}' not found."); + return Command::FAILURE; + } + + // Check if role is being used (optional safety check) + $usageCount = $this->entityManager->getRepository(\App\Entity\UsersOrganizations::class) + ->count(['role' => $role]); + + if ($usageCount > 0) { + $output->writeln("Cannot delete role '{$roleName}' - it is assigned to {$usageCount} user(s)."); + $output->writeln('Remove all assignments first, then try again.'); + return Command::FAILURE; + } + + // Confirmation prompt + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion( + "Are you sure you want to delete role '{$roleName}'? [y/N] ", + false + ); + + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Operation cancelled.'); + return Command::SUCCESS; + } + + // Delete the role + $this->entityManager->remove($role); + $this->entityManager->flush(); + + $output->writeln("Role '{$roleName}' deleted successfully!"); + + return Command::SUCCESS; + } +} +``` +``` bash + php bin/console app:create-role USER + php bin/console app:create-role ADMIN +``` \ No newline at end of file diff --git a/migrations/Version20260216155056.php b/migrations/Version20260216155056.php new file mode 100644 index 0000000..55d547c --- /dev/null +++ b/migrations/Version20260216155056.php @@ -0,0 +1,53 @@ +addSql('DROP SEQUENCE user_organizaton_app_id_seq CASCADE'); + $this->addSql('CREATE TABLE user_organization_app (id SERIAL NOT NULL, user_organization_id INT NOT NULL, application_id INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_active BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_BEF66DF12014CF51 ON user_organization_app (user_organization_id)'); + $this->addSql('CREATE INDEX IDX_BEF66DF13E030ACD ON user_organization_app (application_id)'); + $this->addSql('COMMENT ON COLUMN user_organization_app.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT FK_BEF66DF12014CF51 FOREIGN KEY (user_organization_id) REFERENCES users_organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT FK_BEF66DF13E030ACD FOREIGN KEY (application_id) REFERENCES apps (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT fk_2c952fc72014cf51'); + $this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT fk_2c952fc73e030acd'); + $this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT fk_2c952fc7d60322ac'); + $this->addSql('DROP TABLE user_organizaton_app'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('CREATE SEQUENCE user_organizaton_app_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE user_organizaton_app (id SERIAL NOT NULL, user_organization_id INT NOT NULL, role_id INT DEFAULT NULL, application_id INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_active BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_2c952fc72014cf51 ON user_organizaton_app (user_organization_id)'); + $this->addSql('CREATE INDEX idx_2c952fc73e030acd ON user_organizaton_app (application_id)'); + $this->addSql('CREATE INDEX idx_2c952fc7d60322ac ON user_organizaton_app (role_id)'); + $this->addSql('COMMENT ON COLUMN user_organizaton_app.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT fk_2c952fc72014cf51 FOREIGN KEY (user_organization_id) REFERENCES users_organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT fk_2c952fc73e030acd FOREIGN KEY (application_id) REFERENCES apps (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT fk_2c952fc7d60322ac FOREIGN KEY (role_id) REFERENCES roles (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT FK_BEF66DF12014CF51'); + $this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT FK_BEF66DF13E030ACD'); + $this->addSql('DROP TABLE user_organization_app'); + } +} diff --git a/migrations/Version20260218111608.php b/migrations/Version20260218111608.php new file mode 100644 index 0000000..9796e97 --- /dev/null +++ b/migrations/Version20260218111608.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE project ADD logo VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE project DROP logo'); + } +} diff --git a/migrations/Version20260218111821.php b/migrations/Version20260218111821.php new file mode 100644 index 0000000..04631cd --- /dev/null +++ b/migrations/Version20260218111821.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE project ADD timestamp_precision VARCHAR(10) DEFAULT NULL'); + $this->addSql('ALTER TABLE project ADD deletion_allowed BOOLEAN DEFAULT NULL'); + $this->addSql('ALTER TABLE project ADD audits_enabled BOOLEAN DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE project DROP timestamp_precision'); + $this->addSql('ALTER TABLE project DROP deletion_allowed'); + $this->addSql('ALTER TABLE project DROP audits_enabled'); + } +} diff --git a/src/Command/DeleteRoleCommand.php b/src/Command/DeleteRoleCommand.php index de29992..4290bce 100644 --- a/src/Command/DeleteRoleCommand.php +++ b/src/Command/DeleteRoleCommand.php @@ -51,7 +51,7 @@ class DeleteRoleCommand extends Command } // Check if role is being used (optional safety check) - $usageCount = $this->entityManager->getRepository(\App\Entity\UserOrganizatonApp::class) + $usageCount = $this->entityManager->getRepository(\App\Entity\UsersOrganizations::class) ->count(['role' => $role]); if ($usageCount > 0) { diff --git a/src/Controller/OAuth2Controller.php b/src/Controller/OAuth2Controller.php index 2427772..1f2c008 100644 --- a/src/Controller/OAuth2Controller.php +++ b/src/Controller/OAuth2Controller.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Repository\UsersOrganizationsRepository; use App\Service\AccessTokenService; use App\Service\LoggerService; use App\Service\UserService; @@ -20,18 +21,39 @@ class OAuth2Controller extends AbstractController { - public function __construct(private readonly LoggerService $loggerService, private readonly UserService $userService) + public function __construct(private readonly LoggerService $loggerService, private readonly UserService $userService, + ) { } #[Route('/oauth2/userinfo', name: 'userinfo', methods: ['GET'])] - public function userinfo(Request $request): JsonResponse + public function userinfo(Request $request, UsersOrganizationsRepository $uoRepository): JsonResponse { $user = $this->getUser(); if (!$user) { $this->loggerService->logAccessDenied($user->getId()); return new JsonResponse(['error' => 'Unauthorized'], 401); } + $uos = $uoRepository->findBy(['users' => $user]); + + $result = []; + foreach ($uos as $uo) { + $result[] = ['organization' => [ + 'id' => $uo->getOrganization()->getId(), + 'name' => $uo->getOrganization()->getName(), + 'role' => $uo->getRole()->getName() + ] + ]; + if ($uo->getRole()->getName() === "ADMIN") { + $projets = $uo->getOrganization()->getProjects()->toArray(); + $result[count($result) - 1]['organization']['projects'] = array_map(function ($projet) { + return [ + 'id' => $projet->getId(), +// 'name' => $projet->getName() + ]; + }, $projets); + } + } $this->loggerService->logUserAction($user->getId(), $user->getId(), 'Accessed userinfo endpoint'); return new JsonResponse([ @@ -39,6 +61,7 @@ class OAuth2Controller extends AbstractController 'name' => $user->getName(), 'email' => $user->getEmail(), 'surname' => $user->getSurname(), + 'uos' => $result ]); } @@ -64,7 +87,8 @@ class OAuth2Controller extends AbstractController } #[Route(path: '/oauth2/revoke_tokens', name: 'revoke_tokens', methods: ['POST'])] - public function revokeTokens(Security $security, Request $request, AccessTokenService $accessTokenService, LoggerInterface $logger): Response{ + public function revokeTokens(Security $security, Request $request, AccessTokenService $accessTokenService, LoggerInterface $logger): Response + { //Check if the user have valid access token $data = json_decode($request->getContent(), true); $userIdentifier = $data['user_identifier']; diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index b36cc29..fddc367 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -6,7 +6,7 @@ use App\Entity\Actions; use App\Entity\Apps; use App\Entity\Roles; use App\Entity\User; -use App\Entity\UserOrganizatonApp; +use App\Entity\UserOrganizationApp; use App\Entity\UsersOrganizations; use App\Form\OrganizationForm; use App\Repository\OrganizationsRepository; diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index f6511b0..b5e95eb 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -8,6 +8,7 @@ use App\Repository\AppsRepository; use App\Repository\OrganizationsRepository; use App\Repository\ProjectRepository; use App\Service\ProjectService; +use App\Service\SSO\ProjectService as SSOProjectService; use App\Service\UserService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -21,11 +22,14 @@ use Symfony\Component\Routing\Attribute\Route; final class ProjectController extends AbstractController { + public function __construct(private readonly EntityManagerInterface $entityManager, private readonly OrganizationsRepository $organizationsRepository, private readonly ProjectRepository $projectRepository, private readonly ProjectService $projectService, - private readonly UserService $userService, private readonly AppsRepository $appsRepository) + private readonly AppsRepository $appsRepository, + private readonly SSOProjectService $SSOProjectService, + ) { } @@ -41,38 +45,70 @@ final class ProjectController extends AbstractController public function new(Request $request): JsonResponse { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); - $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); - if (!$data) { - return new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST); - } $org = $this->organizationsRepository->findOneBy(['id' => $data['organizationId']]); - if(!$org) { - return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND); + + $orgId = $request->request->get('organizationId'); + $name = $request->request->get('name'); + $applications = $request->request->all('applications') ?? []; // Expects applications[] from JS + + $org = $this->organizationsRepository->find($orgId); + if (!$org) { + return new JsonResponse(['error' => 'Organization not found'], 404); } - $sanitizedDbName = $this->projectService->getProjectDbName($data['name'], $org->getProjectPrefix()); - if($this->projectRepository->findOneBy(['bddName' => $sanitizedDbName])) { - return new JsonResponse(['error' => 'A project with the same name already exists'], Response::HTTP_CONFLICT); + + // 2. Handle File Upload + $logoFile = $request->files->get('logo'); + $logoPath = null; + + + + // 3. Project Creation + $sanitizedDbName = $this->projectService->getProjectDbName($name, $org->getProjectPrefix()); + if ($this->projectRepository->findOneBy(['bddName' => $sanitizedDbName])) { + return new JsonResponse(['error' => 'A project with the same name already exists'], 409); } - if(!$this->projectService->isApplicationArrayValid($data['applications'])) { - return new JsonResponse(['error' => 'Invalid applications array'], Response::HTTP_BAD_REQUEST); + if ($logoFile) { + $logoPath = $this->projectService->handleLogoUpload($logoFile, $sanitizedDbName); } $project = new Project(); - $project->setName($data['name']); + $project->setName($name); $project->setBddName($sanitizedDbName); $project->setOrganization($org); - $project->setApplications($data['applications']); + $project->setApplications($applications); + + $project->setTimestampPrecision($request->request->get('timestamp')); + $project->setDeletionAllowed($request->request->getBoolean('deletion')); + + if ($logoPath) { + $project->setLogo($logoPath); + } + $this->entityManager->persist($project); - $this->entityManager->flush(); - return new JsonResponse(['message' => 'Project created successfully'], Response::HTTP_CREATED); + $this->entityManager->flush(); //On met le flush avant parce qu'on a besoin de l'ID du projet pour la création distante. + //Oui ducoup c'est chiant parce que le projet est créé même s'il y a une erreur API, mais OH ffs at that point. ducoup s'il y a un pb, vue que la gestion de projet et fait pas le super admin, il faudra recree le projet dans la bdd corespondant à l'appli qui fonctionne pas + // Remote creation logic + + try { + foreach ($project->getApplications() as $appId) { + $app = $this->appsRepository->find($appId); + $clientUrl = 'https://' . $app->getSubDomain() . '.' .$this->getParameter('app_domain') ; + $this->SSOProjectService->createRemoteProject($clientUrl, $project); + } + + } catch (\Exception $e) { + return new JsonResponse(['error' => 'Remote creation failed: ' . $e->getMessage()], 500); + } + + return new JsonResponse(['message' => 'Project created successfully'], 201); } #[Route(path:'/edit/{id}/ajax', name: '_edit', methods: ['POST'])] public function edit(Request $request, int $id): JsonResponse { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); - $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); - if (!$data) { - return new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST); - } $org = $this->organizationsRepository->findOneBy(['id' => $data['organizationId']]); + $orgId = $request->request->get('organizationId'); + $applications = $request->request->all('applications') ?? []; // Expects applications[] from JS + + $org = $this->organizationsRepository->findOneBy(['id' => $orgId]); if(!$org) { return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND); } @@ -80,9 +116,34 @@ final class ProjectController extends AbstractController if(!$project) { return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND); } - $project->setApplications($data['applications']); + $logoFile = $request->files->get('logo'); + $logoPath = null; + + if ($logoFile) { + $logoPath = $this->projectService->handleLogoUpload($logoFile, $project->getBddName()); + } + + $project->setApplications($applications); $project->setModifiedAt(new \DateTimeImmutable()); + $project->setTimestampPrecision($request->request->get('timestamp')); + $project->setDeletionAllowed($request->request->getBoolean('deletion')); + if ($logoPath) { + $project->setLogo($logoPath); + } + $this->entityManager->persist($project); + // Remote editing logic + try { + foreach ($project->getApplications() as $appId) { + $app = $this->appsRepository->find($appId); + $clientUrl = 'https://' . $app->getSubDomain() . '.' .$this->getParameter('app_domain') ; + $this->SSOProjectService->editRemoteProject($clientUrl, $project); + } + + } catch (\Exception $e) { + return new JsonResponse(['error' => 'Remote creation failed: ' . $e->getMessage()], 500); + } + $this->entityManager->flush(); return new JsonResponse(['message' => 'Project updated successfully'], Response::HTTP_OK); } @@ -144,6 +205,8 @@ final class ProjectController extends AbstractController 'id' => $project->getId(), 'name' => ucfirst($project->getName()), 'applications' => $project->getApplications(), + 'timestampPrecision'=> $project->getTimestampPrecision(), + 'deletionAllowed' => $project->isDeletionAllowed(), ]); } @@ -155,6 +218,16 @@ final class ProjectController extends AbstractController return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND); } $project->setIsDeleted(true); + try { + foreach ($project->getApplications() as $appId) { + $app = $this->appsRepository->find($appId); + $clientUrl = 'https://' . $app->getSubDomain() . '.' .$this->getParameter('app_domain') ; + $this->SSOProjectService->deleteRemoteProject($clientUrl, $project->getId()); + } + + } catch (\Exception $e) { + return new JsonResponse(['error' => 'Remote creation failed: ' . $e->getMessage()], 500); + } $this->entityManager->persist($project); $this->entityManager->flush(); return new JsonResponse(['message' => 'Project deleted successfully'], Response::HTTP_OK); diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 64fade1..e197bf6 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -5,7 +5,7 @@ namespace App\Controller; use App\Entity\Apps; use App\Entity\Roles; use App\Entity\User; -use App\Entity\UserOrganizatonApp; +use App\Entity\UserOrganizationApp; use App\Entity\UsersOrganizations; use App\Form\UserForm; use App\Repository\AppsRepository; @@ -195,88 +195,88 @@ class UserController extends AbstractController } - #[Route('/new', name: 'new', methods: ['GET', 'POST'])] - public function new(Request $request): Response - { - $this->denyAccessUnlessGranted('ROLE_USER'); - try { - $actingUser =$this->getUser(); - - $user = new User(); - $form = $this->createForm(UserForm::class, $user); - $form->handleRequest($request); - - $orgId = $request->query->get('organizationId') ?? $request->request->get('organizationId'); - if ($orgId) { - $org = $this->organizationRepository->find($orgId); - if (!$org) { - $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getUserIdentifier()); - $this->addFlash('danger', "L'organisation n'existe pas."); - throw $this->createNotFoundException(self::NOT_FOUND); - } - if (!$this->isGranted('ROLE_ADMIN') && !$this->userService->isAdminOfOrganization($org)) { - $this->loggerService->logAccessDenied($actingUser->getUserIdentifier()); - $this->addFlash('danger', "Accès non autorisé."); - throw $this->createAccessDeniedException(self::ACCESS_DENIED); - } - } else{ - $this->loggerService->logAccessDenied($actingUser->getUserIdentifier()); - $this->addFlash('danger', "Accès non autorisé."); - throw $this->createAccessDeniedException(self::ACCESS_DENIED); - } - - if ($form->isSubmitted() && $form->isValid()) { - $existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]); - - // Case : User exists -> link him to given organization if not already linked, else error message - if ($existingUser && $org) { - $this->userService->addExistingUserToOrganization( - $existingUser, - $org, - ); - - if ($this->isGranted('ROLE_ADMIN')) { - $this->loggerService->logSuperAdmin( - $existingUser->getId(), - $actingUser->getUserIdentifier(), - "Super Admin linked user to organization", - $org->getId(), - ); - } - $this->addFlash('success', 'Utilisateur ajouté avec succès à l\'organisation. '); - return $this->redirectToRoute('organization_show', ['id' => $orgId]); - } - - // Case : user doesn't already exist - - $picture = $form->get('pictureUrl')->getData(); - $this->userService->createNewUser($user, $actingUser, $picture); - - $this->userService->linkUserToOrganization( - $user, - $org, - ); - $this->addFlash('success', 'Nouvel utilisateur créé et ajouté à l\'organisation avec succès. '); - return $this->redirectToRoute('organization_show', ['id' => $orgId]); - } - - return $this->render('user/new.html.twig', [ - 'user' => $user, - 'form' => $form->createView(), - 'organizationId' => $orgId, - ]); - - } catch (\Exception $e) { - $this->errorLogger->critical($e->getMessage()); - - if ($orgId) { - $this->addFlash('danger', 'Une erreur est survenue lors de la création de l\'utilisateur pour l\'organisation .'); - return $this->redirectToRoute('organization_show', ['id' => $orgId]); - } - $this->addFlash('danger', 'Une erreur est survenue lors de la création de l\'utilisateur.'); - return $this->redirectToRoute('user_index'); - } - } +// #[Route('/new', name: 'new', methods: ['GET', 'POST'])] +// public function new(Request $request): Response +// { +// $this->denyAccessUnlessGranted('ROLE_USER'); +// try { +// $actingUser =$this->getUser(); +// +// $user = new User(); +// $form = $this->createForm(UserForm::class, $user); +// $form->handleRequest($request); +// +// $orgId = $request->query->get('organizationId') ?? $request->request->get('organizationId'); +// if ($orgId) { +// $org = $this->organizationRepository->find($orgId); +// if (!$org) { +// $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getUserIdentifier()); +// $this->addFlash('danger', "L'organisation n'existe pas."); +// throw $this->createNotFoundException(self::NOT_FOUND); +// } +// if (!$this->isGranted('ROLE_ADMIN') && !$this->userService->isAdminOfOrganization($org)) { +// $this->loggerService->logAccessDenied($actingUser->getUserIdentifier()); +// $this->addFlash('danger', "Accès non autorisé."); +// throw $this->createAccessDeniedException(self::ACCESS_DENIED); +// } +// } else{ +// $this->loggerService->logAccessDenied($actingUser->getUserIdentifier()); +// $this->addFlash('danger', "Accès non autorisé."); +// throw $this->createAccessDeniedException(self::ACCESS_DENIED); +// } +// +// if ($form->isSubmitted() && $form->isValid()) { +// $existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]); +// +// // Case : User exists -> link him to given organization if not already linked, else error message +// if ($existingUser && $org) { +// $this->userService->addExistingUserToOrganization( +// $existingUser, +// $org, +// ); +// +// if ($this->isGranted('ROLE_ADMIN')) { +// $this->loggerService->logSuperAdmin( +// $existingUser->getId(), +// $actingUser->getUserIdentifier(), +// "Super Admin linked user to organization", +// $org->getId(), +// ); +// } +// $this->addFlash('success', 'Utilisateur ajouté avec succès à l\'organisation. '); +// return $this->redirectToRoute('organization_show', ['id' => $orgId]); +// } +// +// // Case : user doesn't already exist +// +// $picture = $form->get('pictureUrl')->getData(); +// $this->userService->createNewUser($user, $actingUser, $picture); +// +// $this->userService->linkUserToOrganization( +// $user, +// $org, +// ); +// $this->addFlash('success', 'Nouvel utilisateur créé et ajouté à l\'organisation avec succès. '); +// return $this->redirectToRoute('organization_show', ['id' => $orgId]); +// } +// +// return $this->render('user/new.html.twig', [ +// 'user' => $user, +// 'form' => $form->createView(), +// 'organizationId' => $orgId, +// ]); +// +// } catch (\Exception $e) { +// $this->errorLogger->critical($e->getMessage()); +// +// if ($orgId) { +// $this->addFlash('danger', 'Une erreur est survenue lors de la création de l\'utilisateur pour l\'organisation .'); +// return $this->redirectToRoute('organization_show', ['id' => $orgId]); +// } +// $this->addFlash('danger', 'Une erreur est survenue lors de la création de l\'utilisateur.'); +// return $this->redirectToRoute('user_index'); +// } +// } /** * Endpoint to activate/deactivate a user (soft delete) @@ -832,6 +832,174 @@ class UserController extends AbstractController throw $this->createNotFoundException(self::NOT_FOUND); } + #[Route('/new/ajax', name: 'new_ajax', methods: ['POST'])] + public function newUserAjax(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_USER'); + $actingUser = $this->getUser(); + try { + $data = $request->request->all(); + $orgId = $data['organizationId'] ?? null; + $selectedApps = $data['applications'] ?? []; + + //unset data that are not part of the User entity to avoid form errors + unset($data['organizationId'], $data['applications']); + $user = new User(); + + $form = $this->createForm(UserForm::class, $user, [ + 'csrf_protection' => false, + 'allow_extra_fields' => true, + ]); + + $form->submit($data, false); + if (!$orgId) { + return $this->json(['error' => 'ID Organisation manquant.'], 400); + } + + $org = $this->organizationRepository->find($orgId); + if (!$org) { + return $this->json(['error' => "L'organisation n'existe pas."], 404); + } + + // 3. Permissions Check + if (!$this->isGranted('ROLE_ADMIN') && !$this->userService->isAdminOfOrganization($org)) { + $this->loggerService->logAccessDenied($actingUser->getUserIdentifier()); + return $this->json(['error' => "Accès non autorisé."], 403); + } + + if ($form->isSubmitted() && $form->isValid()) { + $email = $user->getEmail(); + $existingUser = $this->userRepository->findOneBy(['email' => $email]); + + // CASE A: User exists -> Add to org + if ($existingUser) { + // Check if already in org to avoid logic errors or duplicate logs + $this->userService->addExistingUserToOrganization($existingUser, $org, $selectedApps); + + if ($this->isGranted('ROLE_ADMIN')) { + $this->loggerService->logSuperAdmin( + $existingUser->getId(), + $actingUser->getUserIdentifier(), + "Super Admin linked user to organization via AJAX", + $org->getId(), + ); + } + + return $this->json([ + 'success' => true, + 'message' => 'Utilisateur existant ajouté à l\'organisation.' + ]); + } + + // CASE B: New User -> Create + // Fetch picture from $request->files since it's a multipart request + $picture = $request->files->get('pictureUrl'); + + $this->userService->createNewUser($user, $actingUser, $picture); + $this->userService->linkUserToOrganization($user, $org, $selectedApps); + + return $this->json([ + 'success' => true, + 'message' => 'Nouvel utilisateur créé et ajouté.' + ]); + } + + // If form is invalid, return the specific errors + return $this->json(['error' => 'Données de formulaire invalides.'], 400); + + } catch (\Exception $e) { + $this->errorLogger->critical("AJAX User Creation Error: " . $e->getMessage()); + return $this->json(['error' => 'Une erreur interne est survenue.'], 500); + } + } + + #[Route('/data/{id}', name: 'user_data_json', methods: ['GET'])] + public function userData(User $user, Request $request): JsonResponse { + $orgId = $request->query->get('orgId'); + $org = $this->organizationRepository->find($orgId); + if (!$org) { + $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $this->getUser()->getUserIdentifier()); + return $this->json(['error' => "L'organisation n'existe pas."], 404); + } + $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $org]); + $apps = $this->userOrganizationAppService->getUserApplicationByOrganization($uo); + return $this->json([ + 'email' => $user->getEmail(), + 'name' => $user->getName(), + 'surname' => $user->getSurname(), + 'phoneNumber' => $user->getPhoneNumber(), + 'applicationIds' => array_map(fn($app) => $app->getId(), $apps), + ]); + } + + #[Route('/edit/{id}/ajax', name: 'edit_ajax', methods: ['POST'])] + public function editAjax(int $id, Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_USER'); + $actingUser = $this->getUser(); + + $user = $this->userRepository->find($id); + if (!$user) { + return $this->json(['error' => "L'utilisateur n'existe pas."], 404); + } + + try { + if (!$this->userService->isAdminOfUser($user)) { + return $this->json(['error' => "Accès non autorisé."], 403); + } + + $data = $request->request->all(); + $orgId = $data['organizationId'] ?? null; + $selectedApps = $data['applications'] ?? []; + + // 1. Clean data for the form (remove non-entity fields) + unset($data['organizationId'], $data['applications']); + + $form = $this->createForm(UserForm::class, $user, [ + 'csrf_protection' => false, + 'allow_extra_fields' => true, + ]); + + $form->submit($data, false); + + if ($form->isSubmitted() && $form->isValid()) { + // 2. Handle User Info & Picture + $picture = $request->files->get('pictureUrl'); + $this->userService->formatUserData($user, $picture); + $user->setModifiedAt(new \DateTimeImmutable('now')); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + // 3. Handle Organization-specific Application Sync + if ($orgId) { + $org = $this->organizationRepository->find($orgId); + if ($org) { + // Logic to sync applications for THIS specific organization + $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $org]); + $this->userOrganizationAppService->syncUserApplicationsByOrganization($uo, $selectedApps); + + // Create Action Log + $this->actionService->createAction("Edit user information", $actingUser, $org, $user->getUserIdentifier()); + } + } + + // 4. Logging + $this->loggerService->logUserAction($user->getId(), $actingUser->getUserIdentifier(), 'User information edited via AJAX'); + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logSuperAdmin($user->getId(), $actingUser->getUserIdentifier(), "Super Admin edited user via AJAX"); + } + + return $this->json(['success' => true, 'message' => 'Informations modifiées avec succès.']); + } + + return $this->json(['error' => 'Données de formulaire invalides.'], 400); + + } catch (\Exception $e) { + $this->errorLogger->critical($e->getMessage()); + return $this->json(['error' => 'Une erreur est survenue lors de la modification.'], 500); + } + } } diff --git a/src/Controller/api/Security/SecurityController.php b/src/Controller/api/Security/SecurityController.php new file mode 100644 index 0000000..ed95b28 --- /dev/null +++ b/src/Controller/api/Security/SecurityController.php @@ -0,0 +1,22 @@ +getUser(); + + return $this->json([ + 'valid' => true, + 'email' => ($user instanceof \App\Entity\User) ? $user->getUserIdentifier() : null, + 'scopes' => $this->container->get('security.token_storage')->getToken()->getScopes(), + ]); + } +} \ No newline at end of file diff --git a/src/Controller/api/v1/user/UserController.php b/src/Controller/api/v1/user/UserController.php new file mode 100644 index 0000000..90a01d0 --- /dev/null +++ b/src/Controller/api/v1/user/UserController.php @@ -0,0 +1,42 @@ +find($id); + if (!$user) { + return $this->json(['error' => 'User not found'], 404); + } + $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); + $uos = $this->uoRepository->findBy(['user' => $user, 'role' => $roleAdmin]); + foreach ($uos as $uo) { + $result[] = [ + 'id' => $uo->getUsers()->getId(), + 'name' => $uo->getUsers()->getName(), + 'email' => $uo->getUsers()->getEmail(), + ]; + } + return $this->json($result); + } +} \ No newline at end of file diff --git a/src/Entity/AccessToken.php b/src/Entity/AccessToken.php index ff51556..5936145 100644 --- a/src/Entity/AccessToken.php +++ b/src/Entity/AccessToken.php @@ -32,4 +32,9 @@ final class AccessToken implements AccessTokenEntityInterface ->getToken($this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey()); } + public function setUserIdentifier(?string $userIdentifier): void + { + $this->userIdentifier = $userIdentifier; + } + } \ No newline at end of file diff --git a/src/Entity/Apps.php b/src/Entity/Apps.php index 99c6f7e..4b97a39 100644 --- a/src/Entity/Apps.php +++ b/src/Entity/Apps.php @@ -38,9 +38,9 @@ class Apps private ?string $descriptionSmall = null; /** - * @var Collection + * @var Collection */ - #[ORM\OneToMany(targetEntity: UserOrganizatonApp::class, mappedBy: 'application')] + #[ORM\OneToMany(targetEntity: UserOrganizationApp::class, mappedBy: 'application')] private Collection $userOrganizatonApps; /** @@ -152,14 +152,14 @@ class Apps } /** - * @return Collection + * @return Collection */ public function getUserOrganizatonApps(): Collection { return $this->userOrganizatonApps; } - public function addUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static + public function addUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static { if (!$this->userOrganizatonApps->contains($userOrganizatonApp)) { $this->userOrganizatonApps->add($userOrganizatonApp); @@ -169,7 +169,7 @@ class Apps return $this; } - public function removeUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static + public function removeUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static { if ($this->userOrganizatonApps->removeElement($userOrganizatonApp)) { if ($userOrganizatonApp->getApplication() === $this) { diff --git a/src/Entity/Organizations.php b/src/Entity/Organizations.php index 470316d..9080665 100644 --- a/src/Entity/Organizations.php +++ b/src/Entity/Organizations.php @@ -56,9 +56,9 @@ class Organizations private Collection $actions; /** - * @var Collection + * @var Collection */ - #[ORM\OneToMany(targetEntity: UserOrganizatonApp::class, mappedBy: 'organization')] + #[ORM\OneToMany(targetEntity: UserOrganizationApp::class, mappedBy: 'organization')] private Collection $userOrganizatonApps; #[ORM\Column(length: 4, nullable: true)] @@ -238,14 +238,14 @@ class Organizations } /** - * @return Collection + * @return Collection */ public function getUserOrganizatonApps(): Collection { return $this->userOrganizatonApps; } - public function addUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static + public function addUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static { if (!$this->userOrganizatonApps->contains($userOrganizatonApp)) { $this->userOrganizatonApps->add($userOrganizatonApp); @@ -255,7 +255,7 @@ class Organizations return $this; } - public function removeUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static + public function removeUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static { if ($this->userOrganizatonApps->removeElement($userOrganizatonApp)) { // set the owning side to null (unless already changed) diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 1512564..d19607a 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -35,9 +35,21 @@ class Project #[ORM\Column] private ?bool $isDeleted = null; - #[ORM\Column(length: 255, nullable: true)] + #[ORM\Column(length: 255)] private ?string $bddName = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $logo = null; + + #[ORM\Column(length: 10)] + private ?string $timestampPrecision = null; + + #[ORM\Column()] + private ?bool $deletionAllowed = null; + + #[ORM\Column(nullable: true)] + private ?bool $auditsEnabled = null; + public function __construct() { $this->createdAt = new \DateTimeImmutable(); @@ -146,4 +158,52 @@ class Project return $this; } + + public function getLogo(): ?string + { + return $this->logo; + } + + public function setLogo(?string $logo): static + { + $this->logo = $logo; + + return $this; + } + + public function getTimestampPrecision(): ?string + { + return $this->timestampPrecision; + } + + public function setTimestampPrecision(?string $timestampPrecision): static + { + $this->timestampPrecision = $timestampPrecision; + + return $this; + } + + public function isDeletionAllowed(): ?bool + { + return $this->deletionAllowed; + } + + public function setDeletionAllowed(?bool $deletionAllowed): static + { + $this->deletionAllowed = $deletionAllowed; + + return $this; + } + + public function isAuditsEnabled(): ?bool + { + return $this->auditsEnabled; + } + + public function setAuditsEnabled(?bool $auditsEnabled): static + { + $this->auditsEnabled = $auditsEnabled; + + return $this; + } } diff --git a/src/Entity/UserOrganizatonApp.php b/src/Entity/UserOrganizationApp.php similarity index 77% rename from src/Entity/UserOrganizatonApp.php rename to src/Entity/UserOrganizationApp.php index ee79cd5..d83513b 100644 --- a/src/Entity/UserOrganizatonApp.php +++ b/src/Entity/UserOrganizationApp.php @@ -2,11 +2,11 @@ namespace App\Entity; -use App\Repository\UserOrganizatonAppRepository; +use App\Repository\UserOrganizationAppRepository; use Doctrine\ORM\Mapping as ORM; -#[ORM\Entity(repositoryClass: UserOrganizatonAppRepository::class)] -class UserOrganizatonApp +#[ORM\Entity(repositoryClass: UserOrganizationAppRepository::class)] +class UserOrganizationApp { #[ORM\Id] #[ORM\GeneratedValue] @@ -16,17 +16,15 @@ class UserOrganizatonApp #[ORM\Column] private ?\DateTimeImmutable $createdAt = null; - #[ORM\ManyToOne] - private ?Roles $role = null; #[ORM\Column] private ?bool $isActive; - #[ORM\ManyToOne(inversedBy: 'userOrganizatonApps')] + #[ORM\ManyToOne(inversedBy: 'userOrganizationApps')] #[ORM\JoinColumn(nullable: false)] private ?UsersOrganizations $userOrganization = null; - #[ORM\ManyToOne(inversedBy: 'userOrganizatonApps')] + #[ORM\ManyToOne(inversedBy: 'userOrganizationApps')] #[ORM\JoinColumn(nullable: false)] private ?Apps $application = null; @@ -53,18 +51,6 @@ class UserOrganizatonApp return $this; } - public function getRole(): ?Roles - { - return $this->role; - } - - public function setRole(?Roles $role): static - { - $this->role = $role; - - return $this; - } - public function isActive(): ?bool { return $this->isActive; diff --git a/src/Entity/UsersOrganizations.php b/src/Entity/UsersOrganizations.php index efe84e0..300debb 100644 --- a/src/Entity/UsersOrganizations.php +++ b/src/Entity/UsersOrganizations.php @@ -30,9 +30,9 @@ class UsersOrganizations private ?\DateTimeImmutable $createdAt = null; /** - * @var Collection + * @var Collection */ - #[ORM\OneToMany(targetEntity: UserOrganizatonApp::class, mappedBy: 'userOrganization')] + #[ORM\OneToMany(targetEntity: UserOrganizationApp::class, mappedBy: 'userOrganization')] private Collection $userOrganizatonApps; #[ORM\Column(length: 255, nullable: true)] @@ -98,14 +98,14 @@ class UsersOrganizations } /** - * @return Collection + * @return Collection */ public function getUserOrganizatonApps(): Collection { return $this->userOrganizatonApps; } - public function addUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static + public function addUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static { if (!$this->userOrganizatonApps->contains($userOrganizatonApp)) { $this->userOrganizatonApps->add($userOrganizatonApp); @@ -115,7 +115,7 @@ class UsersOrganizations return $this; } - public function removeUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static + public function removeUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static { if ($this->userOrganizatonApps->removeElement($userOrganizatonApp)) { // set the owning side to null (unless already changed) diff --git a/src/EventSubscriber/LoginSubscriber.php b/src/EventSubscriber/LoginSubscriber.php index 5438de6..cb74755 100644 --- a/src/EventSubscriber/LoginSubscriber.php +++ b/src/EventSubscriber/LoginSubscriber.php @@ -14,7 +14,8 @@ class LoginSubscriber implements EventSubscriberInterface private EntityManagerInterface $entityManager; - public function __construct(EntityManagerInterface $entityManager) + public function __construct(EntityManagerInterface $entityManager, + private string $clientIdentifier) { $this->entityManager = $entityManager; } @@ -28,19 +29,37 @@ class LoginSubscriber implements EventSubscriberInterface public function onLoginSuccess(LoginSuccessEvent $event): void { - $user = $event->getUser(); - if($user) { - $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $user->getUserIdentifier()]); + $passportUser = $event->getUser(); + + // 1. Check if we have a user at all + if (!$passportUser) { + return; + } + + // 2. IMPORTANT: Check if this is a real User entity from your DB. + // If it's a Machine/Client login, it will be an instance of + // League\Bundle\OAuth2ServerBundle\Security\User\ClientCredentialsUser + if (!$passportUser instanceof \App\Entity\User) { + // It's a machine (M2M), so we don't track "last connection" or create manual tokens + return; + } + + // Now we know it's a real human user + $user = $this->entityManager->getRepository(User::class)->findOneBy([ + 'email' => $passportUser->getUserIdentifier() + ]); + + if ($user) { $user->setLastConnection(new \DateTime('now', new \DateTimeZone('Europe/Paris'))); - $easySolution = $this->entityManager->getRepository(Client::class)->findOneBy(['name' => 'EasySolution']); - if($easySolution) { + $easySolution = $this->entityManager->getRepository(Client::class)->findOneBy(['identifier' => $this->clientIdentifier]); + if ($easySolution) { $accessToken = new AccessToken( - identifier: bin2hex(random_bytes(40)), // Generate unique identifier + identifier: bin2hex(random_bytes(40)), expiry: new \DateTimeImmutable('+1 hour', new \DateTimeZone('Europe/Paris')), client: $easySolution, userIdentifier: $user->getUserIdentifier(), - scopes: ['email profile openid apps:easySolutions'] // Empty array if no specific scopes needed + scopes: ['email', 'profile', 'openid', 'apps:easySolutions'] ); $this->entityManager->persist($user); $this->entityManager->persist($accessToken); diff --git a/src/Repository/AccessTokenRepository.php b/src/Repository/AccessTokenRepository.php index 6ac6134..103262b 100644 --- a/src/Repository/AccessTokenRepository.php +++ b/src/Repository/AccessTokenRepository.php @@ -25,8 +25,7 @@ final class AccessTokenRepository implements AccessTokenRepositoryInterface /** @var int|string|null $userIdentifier */ $accessToken = new AccessTokenEntity(); $accessToken->setClient($clientEntity); - $accessToken->setUserIdentifier($userIdentifier); - + $accessToken->setUserIdentifier($userIdentifier ?? $clientEntity->getIdentifier()); foreach ($scopes as $scope) { $accessToken->addScope($scope); } diff --git a/src/Repository/UserOrganizatonAppRepository.php b/src/Repository/UserOrganizationAppRepository.php similarity index 91% rename from src/Repository/UserOrganizatonAppRepository.php rename to src/Repository/UserOrganizationAppRepository.php index e4e29ff..d1627c9 100644 --- a/src/Repository/UserOrganizatonAppRepository.php +++ b/src/Repository/UserOrganizationAppRepository.php @@ -3,18 +3,18 @@ namespace App\Repository; use App\Entity\User; -use App\Entity\UserOrganizatonApp; +use App\Entity\UserOrganizationApp; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** - * @extends ServiceEntityRepository + * @extends ServiceEntityRepository */ -class UserOrganizatonAppRepository extends ServiceEntityRepository +class UserOrganizationAppRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, UserOrganizatonApp::class); + parent::__construct($registry, UserOrganizationApp::class); } // /** diff --git a/src/Service/OrganizationsService.php b/src/Service/OrganizationsService.php index cfb6263..1835d12 100644 --- a/src/Service/OrganizationsService.php +++ b/src/Service/OrganizationsService.php @@ -5,7 +5,7 @@ namespace App\Service; use App\Entity\Apps; use App\Entity\Organizations; use App\Entity\Roles; -use App\Entity\UserOrganizatonApp; +use App\Entity\UserOrganizationApp; use App\Entity\UsersOrganizations; use App\Repository\UsersOrganizationsRepository; use App\Service\LoggerService; @@ -145,7 +145,7 @@ class OrganizationsService $adminUOs = $this->uoRepository->findBy(['organization' => $data['organization'], 'isActive' => true]); foreach ($adminUOs as $adminUO) { - $uoa = $this->entityManager->getRepository(UserOrganizatonApp::class) + $uoa = $this->entityManager->getRepository(UserOrganizationApp::class) ->findOneBy([ 'userOrganization' => $adminUO, 'role' => $roleAdmin, diff --git a/src/Service/ProjectService.php b/src/Service/ProjectService.php index 11e8d4f..9663c15 100644 --- a/src/Service/ProjectService.php +++ b/src/Service/ProjectService.php @@ -3,12 +3,16 @@ namespace App\Service; use App\Repository\AppsRepository; +use App\Service\LoggerService; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\String\Slugger\AsciiSlugger; class ProjectService{ - public function __construct(private readonly AppsRepository $appsRepository) + + public function __construct(private readonly AppsRepository $appsRepository, private readonly Security $security, private readonly LoggerService $loggerService) { } @@ -40,4 +44,43 @@ class ProjectService{ } return true; } + + public function handleLogoUpload($logoFile, $projectBddName): ?string + { + // 1. Define the destination directory (adjust path as needed, e.g., 'public/uploads/profile_pictures') + $destinationDir = 'uploads/project_logos'; + + // 2. Create the directory if it doesn't exist + if (!file_exists($destinationDir)) { + // 0755 is the standard permission (Owner: read/write/exec, Others: read/exec) + if (!mkdir($destinationDir, 0755, true) && !is_dir($destinationDir)) { + throw new \RuntimeException(sprintf('Directory "%s" was not created', $destinationDir)); + } + } + + $extension = $logoFile->guessExtension(); + + // Sanitize the filename to remove special characters/spaces to prevent filesystem errors + $customFilename = $projectBddName . '.' . $extension; + + try { + // 4. Move the file to the destination directory + $logoFile->move($destinationDir, $customFilename); + + // 5. Update the user entity with the relative path + // Ensure you store the path relative to your public folder usually + return $destinationDir . '/' . $customFilename; + + } catch (\Exception $e) { + // 6. Log the critical error as requested + $this->loggerService->logError('File upload failed',[ + 'target_user_id' => $this->security->getUser()->getId(), + 'message' => $e->getMessage(), + 'file_name' => $customFilename, + ]); + + // Optional: Re-throw the exception if you want the controller/user to know the upload failed + throw new FileException('File upload failed.'); + } + } } diff --git a/src/Service/SSO/ProjectService.php b/src/Service/SSO/ProjectService.php new file mode 100644 index 0000000..a316bda --- /dev/null +++ b/src/Service/SSO/ProjectService.php @@ -0,0 +1,88 @@ + on en a besoin parce que c'est du M2M. + $tokenResponse = $this->getTokenResponse(); + + $accessToken = $tokenResponse->toArray()['access_token']; +// data must match easy check database + $projectJson = $this->getProjectToJson($project); + + + // 2. Call the Client Application's Webhook/API + $this->httpClient->request('POST', $clientAppUrl . '/api/v1/project/create', [ + 'headers' => ['Authorization' => 'Bearer ' . $accessToken], + 'json' => $projectJson + ]); + } + + public function editRemoteProject(string $clientAppUrl, Project $project): void + { + $tokenResponse = $this->getTokenResponse(); + + $accessToken = $tokenResponse->toArray()['access_token']; +// data must match easy check database + $projectJson = $this->getProjectToJson($project); + // 2. Call the Client Application's Webhook/API + $this->httpClient->request('PUT', $clientAppUrl . '/api/v1/project/edit/'. $project->getId(), [ + 'headers' => ['Authorization' => 'Bearer ' . $accessToken], + 'json' => $projectJson + ]); + } + + + public function deleteRemoteProject(string $clientAppUrl, int $projectId): void + { + $tokenResponse = $this->getTokenResponse(); + + $accessToken = $tokenResponse->toArray()['access_token']; + + // 2. Call the Client Application's Webhook/API + $this->httpClient->request('DELETE', $clientAppUrl . '/api/v1/project/delete/'. $projectId, [ + 'headers' => ['Authorization' => 'Bearer ' . $accessToken], + ]); + } + + public function getTokenResponse(): ResponseInterface{ + $portalClient = $this->entityManager->getRepository(Client::class)->findOneBy(['identifier' => $this->clientIdentifier]); + return $this->httpClient->request('POST', $this->appUrl . 'token', [ + 'auth_basic' => [$portalClient->getIdentifier(),$portalClient->getSecret()], // ID and Secret go here + 'body' => [ + 'grant_type' => 'client_credentials', + ], + ]); + } + + public function getProjectToJson(Project $project): array { + return [ + 'id' => $project->getId(), + 'name' => $project->getName(), + 'orgId' => $project->getOrganization()->getId(), + 'orgName' => $project->getOrganization()->getName(), + 'bdd' => $project->getBddName(), + 'isActive' => $project->isActive(), + 'logo' => $project->getLogo(), + 'timestamp'=> $project->getTimestampPrecision(), + 'deletion' => $project->isDeletionAllowed() + ]; + } +} \ No newline at end of file diff --git a/src/Service/UserOrganizationAppService.php b/src/Service/UserOrganizationAppService.php index 53fc759..40166f1 100644 --- a/src/Service/UserOrganizationAppService.php +++ b/src/Service/UserOrganizationAppService.php @@ -5,8 +5,10 @@ namespace App\Service; use App\Entity\Apps; use App\Entity\Roles; use App\Entity\User; -use App\Entity\UserOrganizatonApp; +use App\Entity\UserOrganizationApp; use App\Entity\UsersOrganizations; +use App\Repository\UserOrganizationAppRepository; +use App\Repository\UsersOrganizationsRepository; use App\Service\ActionService; use App\Service\LoggerService; use App\Service\UserService; @@ -16,7 +18,15 @@ use Symfony\Bundle\SecurityBundle\Security; class UserOrganizationAppService { - public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ActionService $actionService, private readonly Security $security, private readonly UserService $userService, private readonly LoggerInterface $logger, private readonly LoggerService $loggerService) + + public function __construct(private readonly EntityManagerInterface $entityManager, + private readonly ActionService $actionService, + private readonly Security $security, + private readonly UserService $userService, + private readonly LoggerInterface $logger, + private readonly LoggerService $loggerService, + private readonly UsersOrganizationsRepository $usersOrganizationsRepository, + private readonly UserOrganizationAppRepository $uoaRepository) { } @@ -76,9 +86,9 @@ class UserOrganizationAppService public function deactivateAllUserOrganizationsAppLinks(UsersOrganizations $userOrganization, Apps $app = null): void { if($app) { - $uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy(['userOrganization' => $userOrganization, 'application' => $app, 'isActive' => true]); + $uoas = $this->uoaRepository->findBy(['userOrganization' => $userOrganization, 'application' => $app, 'isActive' => true]); } else { - $uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy(['userOrganization' => $userOrganization, 'isActive' => true]); + $uoas = $this->uoaRepository->findBy(['userOrganization' => $userOrganization, 'isActive' => true]); } foreach ($uoas as $uoa) { try{ @@ -98,156 +108,6 @@ class UserOrganizationAppService } } - /** - * Synchronizes user roles for a specific application within an organization. - * - * This method handles the complete lifecycle of user-application role assignments: - * - Activates/deactivates existing role links based on selection - * - Creates new role assignments for newly selected roles - * - Updates the user's global Symfony security roles when ADMIN/SUPER_ADMIN roles are assigned - * - * @param UsersOrganizations $uo The user-organization relationship - * @param Apps $application The target application - * @param array $selectedRoleIds Array of role IDs that should be active for this user-app combination - * @param User $actingUser The user performing this action (for audit logging) - * - * @return void - * - * @throws \Exception If role entities cannot be found or persisted - */ - public function syncRolesForUserOrganizationApp( - UsersOrganizations $uo, - Apps $application, - array $selectedRoleIds, - User $actingUser - ): void { - - // Fetch existing UserOrganizationApp links for this user and application - $uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy([ - 'userOrganization' => $uo, - 'application' => $application, - ]); - - $currentRoleIds = []; - // Process existing role links - activate or deactivate based on selection - foreach ($uoas as $uoa) { - $roleId = $uoa->getRole()->getId(); - $currentRoleIds[] = $roleId; - $roleName = $uoa->getRole()->getName(); - - if (in_array((string) $roleId, $selectedRoleIds, true)) { - // Role is selected - ensure it's active - if (!$uoa->isActive()) { - $uoa->setIsActive(true); - $this->entityManager->persist($uoa); - $this->loggerService->logOrganizationInformation( - $uo->getOrganization()->getId(), - $actingUser->getId(), - "Re-activated role '$roleName' for user '{$uo->getUsers()->getId()}' in application '{$application->getName()} with UOA ID {$uoa->getId()}'" - ); - $this->actionService->createAction( - "Re-activate user role for application", - $actingUser, - $uo->getOrganization(), - "App: {$application->getName()}, Role: $roleName for user {$uo->getUsers()->getUserIdentifier()}" - ); - // Sync Admins roles to user's global Symfony security roles - if (in_array($roleName, ['ADMIN', 'SUPER ADMIN'], true)) { - $this->userService->syncUserRoles($uo->getUsers(), $roleName, true); - } - // Ensure ADMIN role is assigned if SUPER ADMIN is activated - if ($roleName === 'SUPER ADMIN') { - $this->ensureAdminRoleForSuperAdmin($uoa); - } - } - } else { - // Role is not selected - ensure it's inactive - if ($uoa->isActive()) { - $uoa->setIsActive(false); - $this->entityManager->persist($uoa); - $this->loggerService->logOrganizationInformation( - $uo->getOrganization()->getId(), - $actingUser->getId(), - "Deactivated role '$roleName' for user '{$uo->getUsers()->getId()}' in application '{$application->getName()}' with UOA ID {$uoa->getId()}'" - ); - $this->actionService->createAction( - "Deactivate user role for application", - $actingUser, - $uo->getOrganization(), - "App: {$application->getName()}, Role: $roleName for user {$uo->getUsers()->getUserIdentifier()}" - ); - // Sync Admins roles to user's global Symfony security roles - if (in_array($roleName, ['ADMIN', 'SUPER ADMIN'], true)) { - $this->userService->syncUserRoles($uo->getUsers(), $roleName, false); - } - - } - } - } - - // Create new role assignments for roles that don't exist yet - foreach ($selectedRoleIds as $roleId) { - if (!in_array($roleId, $currentRoleIds)) { - $role = $this->entityManager->getRepository(Roles::class)->find($roleId); - if ($role) { - // Create new user-organization-application role link - $newUoa = new UserOrganizatonApp(); - $newUoa->setUserOrganization($uo); - $newUoa->setApplication($application); - $newUoa->setRole($role); - $newUoa->setIsActive(true); - - // Sync Admins roles to user's global Symfony security roles - if (in_array($role->getName(), ['ADMIN', 'SUPER ADMIN'], true)) { - $this->userService->syncUserRoles($uo->getUsers(), $role->getName(), true); - } - // Ensure ADMIN role is assigned if SUPER ADMIN is activated - if ($role->getName() === 'SUPER ADMIN') { - $this->ensureAdminRoleForSuperAdmin($newUoa); - } - $this->entityManager->persist($newUoa); - $this->loggerService->logOrganizationInformation( - $uo->getOrganization()->getId(), - $actingUser->getId(), - "Created new role '{$role->getName()}' for user '{$uo->getUsers()->getId()}' in application '{$application->getName()}' with UOA ID {$newUoa->getId()}'" - ); - $this->actionService->createAction("New user role for application", - $actingUser, - $uo->getOrganization(), - "App: {$application->getName()}, Role: {$role->getName()} for user {$uo->getUsers()->getUserIdentifier()}"); - } - } - } - $this->entityManager->flush(); - } - - /** - * Attribute the role Admin to the user if the user has the role Super Admin - * - * @param UserOrganizatonApp $uoa - * - * @return void - */ - public function ensureAdminRoleForSuperAdmin(UserOrganizatonApp $uoa): void - { - $uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy([ - 'userOrganization' => $uoa->getUserOrganization(), - 'application' => $uoa->getApplication(), - 'role' => $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']) - ]); - if(!$uoaAdmin) { - $uoaAdmin = new UserOrganizatonApp(); - $uoaAdmin->setUserOrganization($uoa->getUserOrganization()); - $uoaAdmin->setApplication($uoa->getApplication()); - $uoaAdmin->setRole($this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN'])); - $uoaAdmin->setIsActive(true); - $this->entityManager->persist($uoaAdmin); - } - // If the ADMIN role link exists but is inactive, activate it - if ($uoaAdmin && !$uoaAdmin->isActive()) { - $uoaAdmin->setIsActive(true); - } - } /** * Get users applications links for a given user @@ -257,10 +117,10 @@ class UserOrganizationAppService */ public function getUserApplications(User $user): array { - $uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]); + $uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user, 'isActive' => true]); $apps = []; foreach ($uos as $uo) { - $uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy(['userOrganization' => $uo, 'isActive' => true]); + $uoas = $this->uoaRepository->findBy(['userOrganization' => $uo, 'isActive' => true]); foreach ($uoas as $uoa) { $app = $uoa->getApplication(); if (!in_array($app, $apps, true)) { @@ -270,4 +130,82 @@ class UserOrganizationAppService } return $apps; } + + public function getUserApplicationByOrganization(UsersOrganizations $uo): array + { + $uoas = $this->uoaRepository->findBy(['userOrganization' => $uo, 'isActive' => true]); + $apps = []; + foreach ($uoas as $uoa) { + $app = $uoa->getApplication(); + if (!in_array($app, $apps, true)) { + $apps[] = $app; + } + } + return $apps; + } + + public function syncUserApplicationsByOrganization(UsersOrganizations $uo, array $selectedApps): void + { + // 1. Get all currently active applications for this specific User-Organization link + $currentUolas = $this->uoaRepository->findBy([ + 'userOrganization' => $uo, + 'isActive' => true + ]); + + // Track which app IDs are currently active in the DB + $currentAppIds = array_map(fn($uoa) => $uoa->getApplication()->getId(), $currentUolas); + + // 2. REMOVAL: Deactivate apps that are in the DB but NOT in the new selection + foreach ($currentUolas as $uoa) { + $appId = $uoa->getApplication()->getId(); + if (!in_array($appId, $selectedApps)) { + $uoa->setIsActive(false); + $this->actionService->createAction( + "Deactivate UOA link", + $uo->getUsers(), + $uo->getOrganization(), + "App: " . $uoa->getApplication()->getName() + ); + } + } + + // 3. ADDITION / REACTIVATION: Handle the selected apps + foreach ($selectedApps as $appId) { + $app = $this->entityManager->getRepository(Apps::class)->find($appId); + if (!$app) continue; + + // Check if a record (active or inactive) already exists + $existingUOA = $this->uoaRepository->findOneBy([ + 'userOrganization' => $uo, + 'application' => $app + ]); + + if (!$existingUOA) { + // Create new if it never existed + $newUOA = new UserOrganizationApp(); + $newUOA->setUserOrganization($uo); + $newUOA->setApplication($app); + $newUOA->setIsActive(true); + $this->entityManager->persist($newUOA); + + $this->actionService->createAction( + "Activate UOA link", + $uo->getUsers(), + $uo->getOrganization(), + "App: " . $app->getName() + ); + } elseif (!$existingUOA->isActive()) { + // Reactivate if it was previously disabled + $existingUOA->setIsActive(true); + $this->actionService->createAction( + "Reactivate UOA link", + $uo->getUsers(), + $uo->getOrganization(), + "App: " . $app->getName() + ); + } + } + + $this->entityManager->flush(); + } } diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 2ea60c0..efd96b6 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -3,10 +3,11 @@ namespace App\Service; +use App\Entity\Apps; use App\Entity\Organizations; use App\Entity\Roles; use App\Entity\User; -use App\Entity\UserOrganizatonApp; +use App\Entity\UserOrganizationApp; use App\Entity\UsersOrganizations; use App\Repository\RolesRepository; use DateTimeImmutable; @@ -361,7 +362,7 @@ class UserService ->findBy(['users' => $user, 'isActive' => true]); $hasRole = false; foreach ($uos as $uo) { - $uoa = $this->entityManager->getRepository(UserOrganizatonApp::class) + $uoa = $this->entityManager->getRepository(UserOrganizationApp::class) ->findBy([ 'userOrganization' => $uo, 'isActive' => true, @@ -533,17 +534,17 @@ class UserService * * @param User $user * @param Organizations $organization - * @return void + * @param array $selectedApps + * @return int + * @throws Exception */ - public function handleExistingUser(User $user, Organizations $organization): int + public function reactivateUser(User $user, Organizations $organization, array $selectedApps): int { if (!$user->isActive()) { $user->setIsActive(true); $this->entityManager->persist($user); } - $uo = $this->linkUserToOrganization($user, $organization); - - return $uo->getId(); + return $this->linkUserToOrganization($user, $organization, $selectedApps)->getId(); } /** @@ -554,13 +555,15 @@ class UserService * Handle picture if provided * * @param User $user + * @param $picture + * @param bool $setPassword * @return void */ public function formatUserData(User $user, $picture, bool $setPassword = false): void { // capitalize name and surname $user->setName(ucfirst(strtolower($user->getName()))); - $user->setSurname(ucfirst(strtolower($user->getSurname()))); + $user->setSurname(strtoupper($user->getSurname())); // trim strings $user->setName(trim($user->getName())); @@ -589,11 +592,12 @@ class UserService public function addExistingUserToOrganization( User $existingUser, Organizations $org, + array $selectedApps ): int { + $actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier()); try { - $uoId = $this->handleExistingUser($existingUser, $org); - $actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier()); + $uoId = $this->reactivateUser($existingUser, $org, $selectedApps); $this->loggerService->logExistingUserAddedToOrg( $existingUser->getId(), $org->getId(), @@ -647,6 +651,7 @@ class UserService public function linkUserToOrganization( User $user, Organizations $org, + array $selectedApps ): UsersOrganizations { $actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier()); @@ -660,6 +665,7 @@ class UserService $uo->setRole($roleUser); $uo->setModifiedAt(new \DateTimeImmutable('now')); $this->entityManager->persist($uo); + $this->linkUOToApps($uo, $selectedApps); $this->entityManager->flush(); $this->loggerService->logUserOrganizationLinkCreated( @@ -731,4 +737,18 @@ class UserService } } + private function linkUOToApps(UsersOrganizations $uo, array $selectedApps):void + { + foreach ($selectedApps as $appId){ + $uoa = new UserOrganizationApp(); + $uoa->setUserOrganization($uo); + $app = $this->entityManager->getRepository(Apps::class)->find($appId); + if ($app) { + $uoa->setApplication($app); + $this->entityManager->persist($uoa); + } + } + $this->entityManager->flush(); + } + } diff --git a/templates/organization/show.html.twig b/templates/organization/show.html.twig index 60ce8e1..b83fc9f 100644 --- a/templates/organization/show.html.twig +++ b/templates/organization/show.html.twig @@ -1,7 +1,7 @@ {% extends 'base.html.twig' %} {% block body %} - {% set isSA = is_granted('ROLE_SUPER_ADMIN')%} + {% set isSA = is_granted('ROLE_SUPER_ADMIN') %}
{% for type, messages in app.flashes %} {% for message in messages %} @@ -52,20 +52,75 @@ {# User tables #}
-
+
+
-

- Nouveaux utilisateurs -

-
Ajouter un utilisateur +

Nouveaux utilisateurs

+ {# Button to trigger modal #} +
-
+
+
+ + {# New User Modal #} +
@@ -87,18 +142,21 @@
{# Modal for Adding Admin #} -