From a9493bfb0ff33ec080eb5a82866672e7652f44d0 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 10 Feb 2026 16:01:43 +0100 Subject: [PATCH 01/29] update UO entity to handle roles --- migrations/Version20260210131727.php | 36 ++++++++++++++++++++++++++++ src/Entity/UsersOrganizations.php | 15 ++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 migrations/Version20260210131727.php diff --git a/migrations/Version20260210131727.php b/migrations/Version20260210131727.php new file mode 100644 index 0000000..b313cf2 --- /dev/null +++ b/migrations/Version20260210131727.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE users_organizations ADD role_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE users_organizations ADD CONSTRAINT FK_4B991472D60322AC FOREIGN KEY (role_id) REFERENCES roles (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_4B991472D60322AC ON users_organizations (role_id)'); + } + + 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 users_organizations DROP CONSTRAINT FK_4B991472D60322AC'); + $this->addSql('DROP INDEX IDX_4B991472D60322AC'); + $this->addSql('ALTER TABLE users_organizations DROP role_id'); + } +} diff --git a/src/Entity/UsersOrganizations.php b/src/Entity/UsersOrganizations.php index d524d46..efe84e0 100644 --- a/src/Entity/UsersOrganizations.php +++ b/src/Entity/UsersOrganizations.php @@ -41,6 +41,9 @@ class UsersOrganizations #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $modifiedAt = null; + #[ORM\ManyToOne] + private ?Roles $role = null; + public function __construct() { $this->isActive = true; // Default value for isActive @@ -147,4 +150,16 @@ class UsersOrganizations return $this; } + + public function getRole(): ?Roles + { + return $this->role; + } + + public function setRole(?Roles $role): static + { + $this->role = $role; + + return $this; + } } From 709a9f44cb7a655fedb4d9ac097998ba7f653edf Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 10 Feb 2026 16:01:59 +0100 Subject: [PATCH 02/29] adapt logic to new structure --- src/Service/UserService.php | 69 +++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 09108a6..0f0dd1c 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -8,6 +8,7 @@ use App\Entity\Roles; use App\Entity\User; use App\Entity\UserOrganizatonApp; use App\Entity\UsersOrganizations; +use App\Repository\RolesRepository; use DateTimeImmutable; use DateTimeZone; use Doctrine\ORM\EntityManagerInterface; @@ -33,7 +34,7 @@ class UserService private readonly ActionService $actionService, private readonly EmailService $emailService, private readonly OrganizationsService $organizationsService, - private readonly EventDispatcherInterface $eventDispatcher + private readonly EventDispatcherInterface $eventDispatcher, private readonly RolesRepository $rolesRepository ) { @@ -48,6 +49,23 @@ class UserService return bin2hex(random_bytes(32)); } + /** Check if the user is admin in any organization. + * Return true if the user is admin in at least one organization, false otherwise. + * + * @param User $user + * @return bool + * @throws Exception + */ +// TODO: pas sur de l'utiliser, à vérifier + public function isAdminInAnyOrganization(User $user): bool + { + $roleAdmin = $this->rolesRepository->findOneBy(['name' => 'ADMIN']); + $uoAdmin = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy([ + 'users' => $user, + 'isActive' => true, + 'role'=> $roleAdmin]); + return $uoAdmin !== null; + } /** * Check if the user is currently connected. @@ -75,26 +93,30 @@ class UserService } /** - * Check if the user have the rights to access the page - * Self check can be skipped when checking access for the current user + * Determines if the currently logged-in user has permission to manage or view a target User. + * * Access is granted if: + * 1. The current user is a Super Admin. + * 2. The current user is the target user itself. + * 3. The current user is an active Admin of an organization the target user belongs to. * - * @param User $user - * @param bool $skipSelfCheck - * @return bool - * @throws Exception + * @param User $user The target User object we are checking access against. + * * @return bool True if access is permitted, false otherwise. + * @throws Exception If database or security context issues occur. */ - public function hasAccessTo(User $user, bool $skipSelfCheck = false): bool + public function hasAccessTo(User $user): bool { - if ($this->security->isGranted('ROLE_SUPER_ADMIN')) { + if ($this->security->isGranted('ROLE_ADMIN')) { return true; } - if (!$skipSelfCheck && $user->getUserIdentifier() === $this->security->getUser()->getUserIdentifier()) { +// S'il s'agit de son propre compte, on lui donne accès + if ($user->getUserIdentifier() === $this->security->getUser()->getUserIdentifier()) { return true; } $userOrganizations = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]); if ($userOrganizations) { foreach ($userOrganizations as $uo) { - if ($this->isAdminOfOrganization($uo->getOrganization()) && $uo->getStatut() === "ACCEPTED" && $uo->isActive()) { + //l'utilisateur doit être actif dans l'org, avoir le statut ACCEPTED (double vérif) et être admin de l'org + if ($uo->getStatut() === "ACCEPTED" && $uo->isActive() && $this->isAdminOfOrganization($uo->getOrganization())) { return true; } } @@ -103,11 +125,11 @@ class UserService } + + /** - * Check if the user is an admin of the organization - * A user is considered an admin of an organization if they have the 'ROLE_ADMIN' AND have the link to the - * entity role 'ROLE_ADMIN' in the UsersOrganizationsApp entity - * (if he is admin for any application of the organization). + * Check if the acting user is an admin of the organization + * A user is considered an admin of an organization if they have an active UsersOrganizations link with the role of ADMIN for that organization. * * @param Organizations $organizations * @return bool @@ -116,19 +138,14 @@ class UserService public function isAdminOfOrganization(Organizations $organizations): bool { $actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier()); - $uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $actingUser, 'organization' => $organizations]); $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); - if ($uo) { - $uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, - 'role' => $roleAdmin, - 'isActive' => true]); - if ($uoa && $this->security->isGranted('ROLE_ADMIN')) { - return true; - } - } - return false; - } + $uoAdmin = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $actingUser, + 'organization' => $organizations, + 'role'=> $roleAdmin, + 'isActive' => true]); + return $uoAdmin !== null; + } /** * Get the user by their identifier. From db3a59b389d67c7b8bed1cff1302c44d6a435caf Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 10 Feb 2026 16:02:15 +0100 Subject: [PATCH 03/29] change user view --- assets/styles/app.css | 37 +++- src/Controller/OrganizationController.php | 2 +- src/Controller/UserController.php | 127 ++------------ templates/elements/menu.html.twig | 4 +- templates/user/index.html.twig | 4 +- templates/user/show.html.twig | 204 +++++++++++----------- templates/user/userInformation.html.twig | 7 +- 7 files changed, 165 insertions(+), 220 deletions(-) diff --git a/assets/styles/app.css b/assets/styles/app.css index bde65b2..c819b28 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -4,10 +4,14 @@ --primary-blue-dark : #094754; --black-font: #1D1E1C; --delete : #E42E31; + --delete-dark : #aa1618; --disable : #A3A3A3; - --check : #80F20E; + --check : #5cae09; + --check-dark: #3a6e05; --secondary : #cc664c; --secondary-dark : #a5543d; + --warning : #d2b200; + --warning-dark: #c4a600; } html { @@ -103,12 +107,41 @@ body { border: var(--primary-blue-light); } +.btn-success{ + background: var(--check); + color : #FFFFFF; + border: var(--check-dark); + border-radius: 1rem; +} +.btn-success:hover{ + background: var(--check-dark); + color : #FFFFFF; + border: var(--check); +} + +.btn-warning{ + background: var(--warning); + color : #FFFFFF; + border: var(--warning-dark); + border-radius: 1rem; +} +.btn-warning:hover{ + background: var(--warning-dark); + color : #FFFFFF; + border: var(--warning); +} + .btn-danger{ background: var(--delete); color : #FFFFFF; - border: var(--delete); + border: var(--delete-dark); border-radius: 1rem; } +.btn-danger:hover{ + background: var(--delete-dark); + color : #FFFFFF; + border: var(--delete); +} .color-primary{ color: var(--primary-blue-light) !important; diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index ae7f6fc..9873ed4 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -194,7 +194,7 @@ class OrganizationController extends AbstractController return $this->redirectToRoute('organization_index'); } //check if the user is admin of the organization - if (!$this->userService->isAdminOfOrganization($organization) && !$this->isGranted("ROLE_SUPER_ADMIN")) { + if (!$this->userService->isAdminOfOrganization($organization) && !$this->isGranted("ROLE_ADMIN")) { $this->loggerService->logAccessDenied($actingUser->getId()); $this->addFlash('error', 'Erreur, accès refusé.'); throw new AccessDeniedHttpException('Access denied'); diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 9434546..f07567c 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -62,6 +62,15 @@ class UserController extends AbstractController { } + #[Route(path: '/', name: 'index', methods: ['GET'])] + public function index(): Response + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + $totalUsers = $this->userRepository->count(['isDeleted' => false, 'isActive' => true]); + return $this->render('user/index.html.twig', [ + 'users' => $totalUsers + ]); + } #[Route('/view/{id}', name: 'show', methods: ['GET'])] public function view(int $id, Request $request): Response @@ -72,109 +81,27 @@ class UserController extends AbstractController // Utilisateur courant (acting user) via UserService $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - // Vérification des droits d'accès supplémentaires - - // Chargement de l'utilisateur cible à afficher $user = $this->userRepository->find($id); if (!$user) { $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); - $this->addFlash('error', "L'utilisateur demandé n'existe pas."); + $this->addFlash('danger', "L'utilisateur demandé n'existe pas."); throw $this->createNotFoundException(self::NOT_FOUND); } + //if hasAccessTo is false, turn to true and denie access if (!$this->userService->hasAccessTo($user)) { $this->loggerService->logAccessDenied($actingUser->getId()); - $this->addFlash('error', "L'utilisateur demandé n'existe pas."); + $this->addFlash('danger', "Vous n'avez pas accès à cette information."); throw new AccessDeniedHttpException (self::ACCESS_DENIED); } try { // Paramètre optionnel de contexte organisationnel $orgId = $request->query->get('organizationId'); - - // Liste de toutes les applications (pour créer des groupes même si vides) - $apps = $this->appsRepository->findAll(); - - // Initialisations pour la résolution des UsersOrganizations (UO) - $singleUo = null; - $uoActive = null; - - // get uo or uoS based on orgId if ($orgId) { - // Contexte organisation précis : récupérer l'organisation et les liens UO - $organization = $this->organizationRepository->findBy(['id' => $orgId]); - $uoList = $this->uoRepository->findBy([ - 'users' => $user, - 'organization' => $organization, - 'isActive' => true, - ]); - - if (!$uoList) { - $this->loggerService->logEntityNotFound('UsersOrganization', [ - 'user_id' => $user->getId(), - 'organization_id' => $orgId], - $actingUser->getId()); - $this->addFlash('error', "L'utilisateur n'est pas actif dans cette organisation."); - throw $this->createNotFoundException(self::NOT_FOUND); - } - - // Si contexte org donné, on retient la première UO (singleUo) - $singleUo = $uoList[0]; - $data["singleUo"] = $singleUo; - $uoActive = $singleUo->isActive(); +// TODO: afficher les projets de l'organisation } else { - // Pas de contexte org : récupérer toutes les UO actives de l'utilisateur - $uoList = $this->uoRepository->findBy([ - 'users' => $user, - 'isActive' => true, - ]); - if (!$uoList) { - $data['rolesArray'] = $this->userService->getRolesArrayForUser($actingUser, true); - return $this->render('user/show.html.twig', [ - 'user' => $user, - 'organizationId' => $orgId ?? null, - 'uoActive' => $uoActive ?? null, - 'apps' => $apps ?? [], - 'data' => $data ?? [], - 'canEdit' => false, - ]); - } + // Afficher tous les projets de l'utilisateur } - // Charger les liens UserOrganizationApp (UOA) actifs pour les UO trouvées - // Load user-organization-app roles (can be empty) - $uoa = $this->entityManager - ->getRepository(UserOrganizatonApp::class) - ->findBy([ - 'userOrganization' => $uoList, - 'isActive' => true, - ]); - // Group UOA by app and ensure every app has a group - $data['uoas'] = $this->userOrganizationAppService - ->groupUserOrganizationAppsByApplication( - $uoa, - $apps, - $singleUo ? $singleUo->getId() : null - ); - - //Build roles based on user permissions. - //Admin can't see or edit a super admin user - if ($this->isGranted('ROLE_SUPER_ADMIN')) { - $data['rolesArray'] = $this->rolesRepository->findAll(); - } elseif (!$orgId) { - $data['rolesArray'] = $this->userService->getRolesArrayForUser($actingUser, true); - } else { - $data['rolesArray'] = $this->userService->getRolesArrayForUser($actingUser); - } - - // ------------------------------------------------------------------- - - // Calcul du flag de modification : utilisateur admin ET exactement 1 UO - if (empty($uoa) || !$orgId){ - $canEdit = false; - }else{ - $canEdit = $this->userService->canEditRolesCheck($actingUser, $user, $this->isGranted('ROLE_ADMIN'), $singleUo, $organization); - } - - } catch (\Exception $e) { $this->loggerService->logError('error while loading user information', [ 'target_user_id' => $id, @@ -188,10 +115,6 @@ class UserController extends AbstractController return $this->render('user/show.html.twig', [ 'user' => $user, 'organizationId' => $orgId ?? null, - 'uoActive' => $uoActive ?? null, - 'apps' => $apps ?? [], - 'data' => $data ?? [], - 'canEdit' => $canEdit ?? false, ]); } @@ -404,7 +327,10 @@ class UserController extends AbstractController } } - + /** + * Endpoint to activate/deactivate a user (soft delete) + * If deactivating, also deactivate all org links and revoke tokens + */ #[Route('/activeStatus/{id}', name: 'active_status', methods: ['GET', 'POST'])] public function activeStatus(int $id, Request $request): JsonResponse { @@ -762,23 +688,6 @@ class UserController extends AbstractController ]); } - #[Route(path: '/', name: 'index', methods: ['GET'])] - public function index(): Response - { - $this->isGranted('ROLE_SUPER_ADMIN'); - $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - if ($this->userService->hasAccessTo($actingUser, true) && $this->isGranted("ROLE_ADMIN")) { - $totalUsers = $this->userRepository->count(['isDeleted' => false, 'isActive' => true]); - return $this->render('user/index.html.twig', [ - 'users' => $totalUsers - ]); - } - - //shouldn't be reached normally - $this->loggerService->logAccessDenied($actingUser->getId()); - throw $this->createAccessDeniedException(self::ACCESS_DENIED); - } - /* * AJAX endpoint for new users listing * Get the 5 most recently created users for an organization diff --git a/templates/elements/menu.html.twig b/templates/elements/menu.html.twig index fecb6f2..7e01b6f 100644 --- a/templates/elements/menu.html.twig +++ b/templates/elements/menu.html.twig @@ -3,7 +3,7 @@ {% set current_route = app.request.attributes.get('_route') %} \ No newline at end of file From 4fc059b2a59bf24652b19117ec52563c9a733569 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 11 Feb 2026 15:11:04 +0100 Subject: [PATCH 20/29] Update role logic for organization management --- src/Controller/OrganizationController.php | 131 ++++++--------------- src/Repository/OrganizationsRepository.php | 58 +++++---- 2 files changed, 70 insertions(+), 119 deletions(-) diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index b6d9a40..aa6d27f 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -95,14 +95,14 @@ class OrganizationController extends AbstractController try { $this->entityManager->persist($organization); $this->entityManager->flush(); - $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Created"); - $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(), "Organization Created", $organization->getId()); + $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getUserIdentifier(), "Organization Created"); + $this->loggerService->logSuperAdmin($actingUser->getUserIdentifier(), $actingUser->getUserIdentifier(), "Organization Created", $organization->getId()); $this->actionService->createAction("Create Organization", $actingUser, $organization, $organization->getName()); $this->addFlash('success', 'Organisation crée avec succès.'); return $this->redirectToRoute('organization_index'); } catch (Exception $e) { $this->addFlash('danger', 'Erreur lors de la création de l\'organization'); - $this->loggerService->logError('Error creating organization', ['acting_user_id' => $actingUser->getId(), 'error' => $e->getMessage()]); + $this->loggerService->logError('Error creating organization', ['acting_user_id' => $actingUser->getUserIdentifier(), 'error' => $e->getMessage()]); } } return $this->render('organization/new.html.twig', [ @@ -125,35 +125,12 @@ class OrganizationController extends AbstractController if (!$organization) { $this->loggerService->logEntityNotFound('Organization', [ 'org_id' => $id, - 'message' => 'Organization not found for edit'], $actingUser->getId() + 'message' => 'Organization not found for edit'], $actingUser->getUserIdentifier() ); $this->addFlash('danger', 'Erreur, l\'organization est introuvable.'); return $this->redirectToRoute('organization_index'); } - if (!$this->isGranted("ROLE_SUPER_ADMIN")) { - //check if the user is admin of the organization - $uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $actingUser, 'organization' => $organization]); - if (!$uo) { - $this->loggerService->logEntityNotFound('UO link', [ - 'user_id' => $actingUser->getId(), - 'org_id' => $organization->getId(), - 'message' => 'UO link not found for edit organization' - ], $actingUser->getId()); - $this->addFlash('danger', 'Erreur, accès refusé.'); - return $this->redirectToRoute('organization_index'); - } - $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); - $uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, 'role' => $roleAdmin]); - if (!$uoaAdmin) { - $this->loggerService->logEntityNotFound('UOA link', [ - 'uo_id' => $uo->getId(), - 'role_id' => $roleAdmin->getId(), - 'message' => 'UOA link not found for edit organization, user is not admin of organization' - ], $actingUser->getId()); - $this->addFlash('danger', 'Erreur, accès refusé.'); - return $this->redirectToRoute('organization_index'); - } - } + $form = $this->createForm(OrganizationForm::class, $organization); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { @@ -164,16 +141,16 @@ class OrganizationController extends AbstractController try { $this->entityManager->persist($organization); $this->entityManager->flush(); - $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Edited"); + $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getUserIdentifier(), "Organization Edited"); if ($this->isGranted("ROLE_SUPER_ADMIN")) { - $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(), "Organization Edited", $organization->getId()); + $this->loggerService->logSuperAdmin($actingUser->getUserIdentifier(), $actingUser->getUserIdentifier(), "Organization Edited", $organization->getId()); } $this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName()); $this->addFlash('success', 'Organisation modifiée avec succès.'); return $this->redirectToRoute('organization_index'); }catch (Exception $e) { $this->addFlash('danger', 'Erreur lors de la modification de l\'organization'); - $this->loggerService->logError('Error editing organization', ['acting_user_id' => $actingUser->getId(), 'error' => $e->getMessage()]); + $this->loggerService->logError('Error editing organization', ['acting_user_id' => $actingUser->getUserIdentifier(), 'error' => $e->getMessage()]); } } return $this->render('organization/edit.html.twig', [ @@ -192,17 +169,18 @@ class OrganizationController extends AbstractController $this->loggerService->logEntityNotFound('Organization', [ 'org_id' => $id, 'message' => 'Organization not found for view' - ], $actingUser->getId()); + ], $actingUser->getUserIdentifier()); $this->addFlash('danger', 'Erreur, l\'organization est introuvable.'); return $this->redirectToRoute('organization_index'); } //check if the user is admin of the organization if (!$this->userService->isAdminOfOrganization($organization) && !$this->isGranted("ROLE_ADMIN")) { - $this->loggerService->logAccessDenied($actingUser->getId()); + $this->loggerService->logAccessDenied($actingUser->getUserIdentifier()); $this->addFlash('danger', 'Erreur, accès refusé.'); throw new AccessDeniedHttpException('Access denied'); } + //TODO: add project to the response $allApps = $this->entityManager->getRepository(Apps::class)->findAll(); // appsAll $orgApps = $organization->getApps()->toArray(); // apps @@ -222,14 +200,14 @@ class OrganizationController extends AbstractController #[Route(path: '/delete/{id}', name: 'delete', methods: ['POST'])] public function delete($id): Response { - $this->denyAccessUnlessGranted("ROLE_ADMIN"); + $this->denyAccessUnlessGranted("ROLE_SUPER_ADMIN"); $actingUser = $this->getUser(); $organization = $this->organizationsRepository->find($id); if (!$organization) { $this->loggerService->logEntityNotFound('Organization', [ 'org_id' => $id, 'message' => 'Organization not found for delete' - ], $actingUser->getId()); + ], $actingUser->getUserIdentifier()); $this->addFlash('danger', 'Erreur, l\'organization est introuvable.'); throw $this->createNotFoundException(self::NOT_FOUND); } @@ -243,13 +221,13 @@ class OrganizationController extends AbstractController $this->entityManager->persist($organization); $this->actionService->createAction("Delete Organization", $actingUser, $organization, $organization->getName()); $this->entityManager->flush(); - $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(),'Organization Deleted'); + $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getUserIdentifier(),'Organization Deleted'); if ($this->isGranted("ROLE_SUPER_ADMIN")) { - $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization Deleted', $organization->getId()); + $this->loggerService->logSuperAdmin($actingUser->getUserIdentifier(), $actingUser->getUserIdentifier(),'Organization Deleted', $organization->getId()); } $this->addFlash('success', 'Organisation supprimée avec succès.'); }catch (\Exception $e){ - $this->loggerService->logError($actingUser->getId(), ['message' => 'Error deleting organization: '.$e->getMessage()]); + $this->loggerService->logError($actingUser->getUserIdentifier(), ['message' => 'Error deleting organization: '.$e->getMessage()]); $this->addFlash('danger', 'Erreur lors de la suppression de l\'organization.'); } @@ -266,16 +244,15 @@ class OrganizationController extends AbstractController $this->loggerService->logEntityNotFound('Organization', [ 'org_id' => $id, 'message' => 'Organization not found for deactivate' - ], $actingUser->getId()); + ], $actingUser->getUserIdentifier()); $this->addFlash('danger', 'Erreur, l\'organization est introuvable.'); throw $this->createNotFoundException(self::NOT_FOUND); } $organization->setIsActive(false); -// $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization); $this->entityManager->persist($organization); $this->actionService->createAction("Deactivate Organization", $actingUser, $organization, $organization->getName()); - $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization deactivated', $organization->getId()); + $this->loggerService->logSuperAdmin($actingUser->getUserIdentifier(), $actingUser->getUserIdentifier(),'Organization deactivated', $organization->getId()); $this->addFlash('success', 'Organisation désactivé avec succès.'); return $this->redirectToRoute('organization_index'); } @@ -290,14 +267,14 @@ class OrganizationController extends AbstractController $this->loggerService->logEntityNotFound('Organization', [ 'org_id' => $id, 'message' => 'Organization not found for activate' - ], $actingUser->getId()); + ], $actingUser->getUserIdentifier()); $this->addFlash('danger', 'Erreur, l\'organization est introuvable.'); throw $this->createNotFoundException(self::NOT_FOUND); } $organization->setIsActive(true); $this->entityManager->persist($organization); - $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(),'Organization Activated'); - $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization Activated', $organization->getId()); + $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getUserIdentifier(),'Organization Activated'); + $this->loggerService->logSuperAdmin($actingUser->getUserIdentifier(), $actingUser->getUserIdentifier(),'Organization Activated', $organization->getId()); $this->actionService->createAction("Activate Organization", $actingUser, $organization, $organization->getName()); $this->addFlash('success', 'Organisation activée avec succès.'); return $this->redirectToRoute('organization_index'); @@ -309,54 +286,21 @@ class OrganizationController extends AbstractController { $this->denyAccessUnlessGranted('ROLE_USER'); - - $page = max(1, (int)$request->query->get('page', 1)); - $size = max(1, (int)$request->query->get('size', 10)); - + $page = max(1, $request->query->getInt('page', 1)); + $size = max(1, $request->query->getInt('size', 10)); $filters = $request->query->all('filter'); + // Fetch paginated results + $paginator = $this->organizationsRepository->findAdmissibleOrganizations( + $this->getUser(), + $this->isGranted('ROLE_ADMIN'), // Super Admin check + $page, + $size, + $filters + ); - $qb = $this->organizationsRepository->createQueryBuilder('o') - ->where('o.isDeleted = :del')->setParameter('del', false); + $total = count($paginator); - if (!empty($filters['name'])) { - $qb->andWhere('o.name LIKE :name') - ->setParameter('name', '%' . $filters['name'] . '%'); - } - if (!empty($filters['email'])) { - $qb->andWhere('o.email LIKE :email') - ->setParameter('email', '%' . $filters['email'] . '%'); - } - if (!$this->isGranted('ROLE_ADMIN')) { - $actingUser = $this->getUser(); - $uo = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $actingUser]); - - $allowedOrgIds = []; - foreach ($uo as $item) { - if ($this->userService->isAdminOfOrganization($item->getOrganization())) { - $allowedOrgIds[] = $item->getOrganization()->getId(); - } - } - - // If user has no organizations, ensure query returns nothing (or handle typically) - if (empty($allowedOrgIds)) { - $qb->andWhere('1 = 0'); // Force empty result - } else { - $qb->andWhere('o.id IN (:orgIds)') - ->setParameter('orgIds', $allowedOrgIds); - } - } - - - // Count total - $countQb = clone $qb; - $total = (int)$countQb->select('COUNT(o.id)')->getQuery()->getSingleScalarResult(); - - // Pagination - $offset = ($page - 1) * $size; - $rows = $qb->setFirstResult($offset)->setMaxResults($size)->getQuery()->getResult(); - - // Map to array $data = array_map(function (Organizations $org) { return [ 'id' => $org->getId(), @@ -366,17 +310,12 @@ class OrganizationController extends AbstractController 'active' => $org->isActive(), 'showUrl' => $this->generateUrl('organization_show', ['id' => $org->getId()]), ]; - }, $rows); - - $lastPage = (int)ceil($total / $size); + }, iterator_to_array($paginator)); return $this->json([ 'data' => $data, - 'last_page' => $lastPage, - 'total' => $total, // optional, useful for debugging + 'last_page' => (int)ceil($total / $size), + 'total' => $total, ]); } - - - } diff --git a/src/Repository/OrganizationsRepository.php b/src/Repository/OrganizationsRepository.php index e598d9f..87d4772 100644 --- a/src/Repository/OrganizationsRepository.php +++ b/src/Repository/OrganizationsRepository.php @@ -3,8 +3,11 @@ namespace App\Repository; use App\Entity\Organizations; +use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\Tools\Pagination\Paginator; use Doctrine\Persistence\ManagerRegistry; +use App\Entity\UsersOrganizations; /** * @extends ServiceEntityRepository @@ -16,28 +19,37 @@ class OrganizationsRepository extends ServiceEntityRepository parent::__construct($registry, Organizations::class); } - // /** - // * @return Organizations[] Returns an array of Organizations objects - // */ - // public function findByExampleField($value): array - // { - // return $this->createQueryBuilder('o') - // ->andWhere('o.exampleField = :val') - // ->setParameter('val', $value) - // ->orderBy('o.id', 'ASC') - // ->setMaxResults(10) - // ->getQuery() - // ->getResult() - // ; - // } + public function findAdmissibleOrganizations(User $user, bool $isSuperAdmin, int $page, int $size, array $filters = []): Paginator + { + $qb = $this->createQueryBuilder('o') + ->where('o.isDeleted = :del') + ->setParameter('del', false); - // public function findOneBySomeField($value): ?Organizations - // { - // return $this->createQueryBuilder('o') - // ->andWhere('o.exampleField = :val') - // ->setParameter('val', $value) - // ->getQuery() - // ->getOneOrNullResult() - // ; - // } + // 1. Security Logic: If not Super Admin, join UsersOrganizations to filter + if (!$isSuperAdmin) { + $qb->innerJoin(UsersOrganizations::class, 'uo', 'WITH', 'uo.organization = o') + ->andWhere('uo.users = :user') + ->andWhere('uo.role = :roleAdmin') + ->andWhere('uo.isActive = true') + ->setParameter('user', $user) + // You can pass the actual Role entity or the string name depending on your mapping + ->setParameter('roleAdmin', $this->_em->getRepository(\App\Entity\Roles::class)->findOneBy(['name' => 'ADMIN'])); + } + + // 2. Filters + if (!empty($filters['name'])) { + $qb->andWhere('o.name LIKE :name') + ->setParameter('name', '%' . $filters['name'] . '%'); + } + if (!empty($filters['email'])) { + $qb->andWhere('o.email LIKE :email') + ->setParameter('email', '%' . $filters['email'] . '%'); + } + + // 3. Pagination + $qb->setFirstResult(($page - 1) * $size) + ->setMaxResults($size); + + return new Paginator($qb); + } } From d0898150699afd0e6b13f7416b525fed9b0532b6 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 11 Feb 2026 15:13:47 +0100 Subject: [PATCH 21/29] Update role logic for action display --- src/Controller/ActionController.php | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Controller/ActionController.php b/src/Controller/ActionController.php index 0b02473..0815dc5 100644 --- a/src/Controller/ActionController.php +++ b/src/Controller/ActionController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Entity\Actions; use App\Entity\Organizations; use App\Service\ActionService; +use App\Service\UserService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -15,21 +16,24 @@ class ActionController extends AbstractController { public function __construct( private EntityManagerInterface $entityManager, - private ActionService $actionService + private ActionService $actionService, private readonly UserService $userService ) { } #[Route('/organization/{id}/activities-ajax', name: 'app_organization_activities_ajax', methods: ['GET'])] public function fetchActivitiesAjax(Organizations $organization): JsonResponse { - $this->denyAccessUnlessGranted('ROLE_ADMIN'); - $actions = $this->entityManager->getRepository(Actions::class)->findBy( - ['Organization' => $organization], - ['date' => 'DESC'], - 10 - ); - $formattedActivities = $this->actionService->formatActivities($actions); + $this->denyAccessUnlessGranted('ROLE_USER'); + if($this->userService->isAdminOfOrganization($organization)){ + $actions = $this->entityManager->getRepository(Actions::class)->findBy( + ['Organization' => $organization], + ['date' => 'DESC'], + 10 + ); + $formattedActivities = $this->actionService->formatActivities($actions); - return new JsonResponse($formattedActivities); + return new JsonResponse($formattedActivities); + } + return new JsonResponse(['error' => 'You are not authorized to access this page.'], 403); } } From 42bee789ba6ee0a16080d1326860a6221805415c Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 11 Feb 2026 15:16:25 +0100 Subject: [PATCH 22/29] Removed dead code --- src/Controller/MercureController.php | 2 +- src/Controller/NotificationController.php | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Controller/MercureController.php b/src/Controller/MercureController.php index 096871b..93548ae 100644 --- a/src/Controller/MercureController.php +++ b/src/Controller/MercureController.php @@ -20,7 +20,7 @@ class MercureController extends AbstractController public function getMercureToken(Request $request): JsonResponse { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $user =$this->getUser(); $domain = $request->getSchemeAndHttpHost(); diff --git a/src/Controller/NotificationController.php b/src/Controller/NotificationController.php index 9195577..9d32d6c 100644 --- a/src/Controller/NotificationController.php +++ b/src/Controller/NotificationController.php @@ -29,7 +29,7 @@ class NotificationController extends AbstractController public function index(): JsonResponse { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $user =$this->getUser(); $notifications = $this->notificationRepository->findRecentByUser($user, 50); $unreadCount = $this->notificationRepository->countUnreadByUser($user); @@ -44,7 +44,7 @@ class NotificationController extends AbstractController public function unread(): JsonResponse { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $user =$this->getUser(); $notifications = $this->notificationRepository->findUnreadByUser($user); $unreadCount = count($notifications); @@ -59,7 +59,7 @@ class NotificationController extends AbstractController public function count(): JsonResponse { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $user =$this->getUser(); $unreadCount = $this->notificationRepository->countUnreadByUser($user); @@ -70,7 +70,7 @@ class NotificationController extends AbstractController public function markAsRead(int $id): JsonResponse { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $user =$this->getUser(); $notification = $this->notificationRepository->find($id); @@ -88,7 +88,7 @@ class NotificationController extends AbstractController public function markAllAsRead(): JsonResponse { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $user =$this->getUser(); $count = $this->notificationRepository->markAllAsReadForUser($user); @@ -99,7 +99,7 @@ class NotificationController extends AbstractController public function delete(int $id): JsonResponse { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $user =$this->getUser(); $notification = $this->notificationRepository->find($id); From e536a5ebc5bf06a6e309220ea721f69b981b8d51 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 11 Feb 2026 15:22:11 +0100 Subject: [PATCH 23/29] update logic to fit new role rework --- src/Controller/IndexController.php | 8 ++++- src/Controller/OrganizationController.php | 36 +++++++++++++---------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/Controller/IndexController.php b/src/Controller/IndexController.php index 5bfd14b..ae0e8b4 100644 --- a/src/Controller/IndexController.php +++ b/src/Controller/IndexController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Service\UserService; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Security; @@ -11,10 +12,15 @@ use Symfony\Component\Routing\Attribute\Route; final class IndexController extends AbstractController { + public function __construct(private readonly UserService $userService) + { + } + #[Route('/', name: 'app_index')] public function index(): Response { - if ($this->isGranted('ROLE_ADMIN')) { + + if ($this->isGranted('ROLE_ADMIN') || ($this->isGranted('ROLE_USER') && $this->userService->isAdminInAnyOrganization($this->getUser()))) { return $this->redirectToRoute('organization_index'); } diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index aa6d27f..f3ce97e 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -52,29 +52,33 @@ class OrganizationController extends AbstractController { $this->denyAccessUnlessGranted('ROLE_USER'); $actingUser = $this->getUser(); - if ($this->userService->isAdminInAnyOrganization($actingUser)) { - $orgs = $this->userOrganizationService->getAdminOrganizationsForUser($actingUser); + + // 1. Super Admin Case: Just show the list + if ($this->isGranted("ROLE_ADMIN")) { + return $this->render('organization/index.html.twig', ['hasOrganizations' => true]); } - if (!$this->isGranted("ROLE_ADMIN") && !empty($orgs)) { - if (count($orgs) === 1) { - return $this->redirectToRoute('organization_show', ['id' => $orgs[0]->getId()]); - } - return $this->render('organization/index.html.twig', [ - 'hasOrganizations' => $orgs > 1 - ]); + + // 2. Organization Admin Case: Get their specific orgs + $orgs = $this->userOrganizationService->getAdminOrganizationsForUser($actingUser); + + // If exactly one org, jump straight to it + if (count($orgs) === 1) { + return $this->redirectToRoute('organization_show', ['id' => $orgs[0]->getId()]); } - if ($this->isgranted("ROLE_ADMIN")) { - return $this->render('organization/index.html.twig', [ - 'hasOrganizations' => true - ]); + + // If multiple orgs, show the list + if (count($orgs) > 1) { + return $this->render('organization/index.html.twig', ['hasOrganizations' => true]); } + + // 3. Fallback: No access/No orgs found $this->loggerService->logEntityNotFound('Organization', [ 'user_id' => $actingUser->getUserIdentifier(), - 'message' => 'No admin organizations found for user in organization index' + 'message' => 'No admin organizations found' ], $actingUser->getUserIdentifier()); - $this->addFlash('danger', 'Erreur, aucune organisation trouvée.'); - return $this->redirectToRoute('home'); + $this->addFlash('danger', 'Erreur, aucune organisation trouvée.'); + return $this->redirectToRoute('app_index'); } From f1d219544be4d8371a20be6920ab436349c54a1c Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 11 Feb 2026 15:24:26 +0100 Subject: [PATCH 24/29] update logic to fit new role rework --- src/Controller/ApplicationController.php | 112 +---------------------- 1 file changed, 1 insertion(+), 111 deletions(-) diff --git a/src/Controller/ApplicationController.php b/src/Controller/ApplicationController.php index 5f86963..47d3665 100644 --- a/src/Controller/ApplicationController.php +++ b/src/Controller/ApplicationController.php @@ -51,7 +51,7 @@ class ApplicationController extends AbstractController #[Route(path: '/edit/{id}', name: 'edit', methods: ['GET', 'POST'])] public function edit(int $id, Request $request): Response{ $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); - $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $actingUser = $this->getUser(); $application = $this->entityManager->getRepository(Apps::class)->find($id); if (!$application) { $this->loggerService->logEntityNotFound('Application', [ @@ -101,114 +101,4 @@ class ApplicationController extends AbstractController ]); } - - #[Route(path: '/authorize/{id}', name: 'authorize', methods: ['POST'])] - public function authorize(int $id, Request $request): Response - { - $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); - $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - try{ - $application = $this->entityManager->getRepository(Apps::class)->find($id); - if (!$application) { - $this->loggerService->logEntityNotFound('Application', [ - 'applicationId' => $id, - 'message' => "Application not found for authorization." - ], $actingUser->getId()); - throw $this->createNotFoundException("L'application n'existe pas."); - } - $orgId = $request->get('organizationId'); - - $organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId); - if (!$organization) { - $this->loggerService->logEntityNotFound('Organization', [ - 'Organization_id' => $orgId, - 'message' => "Organization not found for authorization." - ], $actingUser->getId()); - throw $this->createNotFoundException("L'Organization n'existe pas."); - } - $application->addOrganization($organization); - $this->loggerService->logApplicationInformation('Application Authorized', [ - 'applicationId' => $application->getId(), - 'applicationName' => $application->getName(), - 'organizationId' => $organization->getId(), - 'message' => "Application authorized for organization." - ], $actingUser->getId()); - $this->entityManager->persist($application); - $this->entityManager->flush(); - $this->actionService->createAction("Authorization d'accès", $actingUser, $organization, $application->getName()); - return new Response('', Response::HTTP_OK); - }catch (HttpExceptionInterface $e){ - throw $e; - } catch (\Exception $e){ - $this->loggerService->logError('Application Authorization Failed', [ - 'applicationId' => $id, - 'error' => $e->getMessage(), - 'message' => "Failed to authorize application.", - 'acting_user_id' => $actingUser->getId() - ]); - return new Response('Erreur lors de l\'autorisation de l\'application.', Response::HTTP_INTERNAL_SERVER_ERROR); - } - - - } - - #[Route(path: '/revoke/{id}', name: 'revoke', methods: ['POST'])] - public function revoke(int $id, Request $request) - { - $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); - $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - $application = $this->entityManager->getRepository(Apps::class)->find($id); - if (!$application) { - $this->loggerService->logEntityNotFound('Application', [ - 'applicationId' => $id, - 'message' => "Application not found for authorization removal." - ], $actingUser->getId()); - throw $this->createNotFoundException("L'application n'existe pas."); - } - $orgId = $request->get('organizationId'); - $organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId); - if (!$organization) { - $this->loggerService->logEntityNotFound('Organization', [ - 'Organization_id' => $orgId, - 'message' => "Organization not found for authorization removal." - ], $actingUser->getId()); - throw $this->createNotFoundException("L'Organization n'existe pas."); - } - $application->removeOrganization($organization); - $this->loggerService->logApplicationInformation('Application Authorized removed', [ - 'applicationId' => $application->getId(), - 'applicationName' => $application->getName(), - 'organizationId' => $organization->getId(), - 'message' => "Application authorized removed for organization." - ], $actingUser->getId()); - $this->actionService->createAction("Authorization retirer", $actingUser, $organization, $application->getName()); - - return new Response('', Response::HTTP_OK); - } - - #[Route(path:'/user/{id}', name: 'user', methods: ['GET'])] - public function getApplicationUsers(int $id): JSONResponse - { - $user = $this->userRepository->find($id); - $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - if (!$user) { - $this->loggerService->logEntityNotFound('User', ['message'=> 'User not found for application list'], $actingUser->getId()); - return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND); - } - if ($this->isGranted('ROLE_SUPER_ADMIN')) { - $applications = $this->entityManager->getRepository(Apps::class)->findAll(); - }else{ - $applications = $this->userOrganizationAppService->getUserApplications($user); - - } - $data = array_map(function($app) { - return [ - 'name' => $app->getName(), - 'subDomain' => $app->getSubDomain(), - 'logoMiniUrl' => $this->assetsManager->getUrl($app->getLogoMiniUrl()), - ]; - }, $applications); - - return new JsonResponse($data, Response::HTTP_OK); - } } From c6833232a020fdc1523cf624ab75bc7e1c52a3f9 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 11 Feb 2026 15:45:39 +0100 Subject: [PATCH 25/29] Generate random prefix for organization projects --- migrations/Version20260211142643.php | 32 +++++++++++++++++++++++ src/Controller/OrganizationController.php | 1 + src/Entity/Organizations.php | 15 +++++++++++ src/Service/LoggerService.php | 14 +++++----- src/Service/OrganizationsService.php | 15 +++++++++++ 5 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 migrations/Version20260211142643.php diff --git a/migrations/Version20260211142643.php b/migrations/Version20260211142643.php new file mode 100644 index 0000000..584ce6c --- /dev/null +++ b/migrations/Version20260211142643.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE organizations ADD project_prefix VARCHAR(4) 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 organizations DROP project_prefix'); + } +} diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index f3ce97e..89813ed 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -97,6 +97,7 @@ class OrganizationController extends AbstractController $this->organizationsService->handleLogo($organization, $logoFile); } try { + $organization->setProjectPrefix($this->organizationsService->generateUniqueProjectPrefix()); $this->entityManager->persist($organization); $this->entityManager->flush(); $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getUserIdentifier(), "Organization Created"); diff --git a/src/Entity/Organizations.php b/src/Entity/Organizations.php index 281697c..6cbce6a 100644 --- a/src/Entity/Organizations.php +++ b/src/Entity/Organizations.php @@ -61,6 +61,9 @@ class Organizations #[ORM\OneToMany(targetEntity: UserOrganizatonApp::class, mappedBy: 'organization')] private Collection $userOrganizatonApps; + #[ORM\Column(length: 4, nullable: true)] + private ?string $projectPrefix = null; + public function __construct() { $this->apps = new ArrayCollection(); @@ -256,4 +259,16 @@ class Organizations return $this; } + + public function getProjectPrefix(): ?string + { + return $this->projectPrefix; + } + + public function setProjectPrefix(?string $projectPrefix): static + { + $this->projectPrefix = $projectPrefix; + + return $this; + } } diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php index f7167b4..75f4e86 100644 --- a/src/Service/LoggerService.php +++ b/src/Service/LoggerService.php @@ -23,7 +23,7 @@ readonly class LoggerService // User Management Logs - public function logUserCreated(int $userId, int|string $actingUserId): void + public function logUserCreated(int|string $userId, int|string $actingUserId): void { $this->userManagementLogger->notice("New user created: $userId", [ 'target_user_id' => $userId, @@ -34,7 +34,7 @@ readonly class LoggerService } // Organization Management Logs - public function logUserOrganizationLinkCreated(int $userId, int $orgId, int|string $actingUserId, ?int $uoId): void + public function logUserOrganizationLinkCreated(int|string $userId, int $orgId, int|string $actingUserId, ?int $uoId): void { $this->organizationManagementLogger->notice('User-Organization link created', [ 'target_user_id' => $userId, @@ -46,7 +46,7 @@ readonly class LoggerService ]); } - public function logExistingUserAddedToOrg(int $userId, int $orgId, int|string $actingUserId, int $uoId): void + public function logExistingUserAddedToOrg(int|string $userId, int $orgId, int|string $actingUserId, int $uoId): void { $this->organizationManagementLogger->notice('Existing user added to organization', [ 'target_user_id' => $userId, @@ -59,7 +59,7 @@ readonly class LoggerService } // Email Notification Logs - public function logEmailSent(int $userId, ?int $orgId, string $message): void + public function logEmailSent(int|string $userId, ?int $orgId, string $message): void { $this->emailNotificationLogger->notice($message, [ 'target_user_id' => $userId, @@ -69,7 +69,7 @@ readonly class LoggerService ]); } - public function logExistingUserNotificationSent(int $userId, int $orgId): void + public function logExistingUserNotificationSent(int|string $userId, int $orgId): void { $this->emailNotificationLogger->notice("Existing user notification email sent to $userId", [ 'target_user_id' => $userId, @@ -87,7 +87,7 @@ readonly class LoggerService ])); } - public function logSuperAdmin(int $userId, int|string $actingUserId, string $message, ?int $orgId = null): void + public function logSuperAdmin(int|string $userId, int|string $actingUserId, string $message, ?int $orgId = null): void { $this->adminActionsLogger->notice($message, [ 'target_user_id' => $userId, @@ -202,7 +202,7 @@ readonly class LoggerService ]); } - public function logRoleEntityAssignment(int $userId, int $organizationId, int $roleId, int|string $actingUserId, string $message): void + public function logRoleEntityAssignment(int|string $userId, int $organizationId, int $roleId, int|string $actingUserId, string $message): void { $this->accessControlLogger->info($message, [ 'target_user_id' => $userId, diff --git a/src/Service/OrganizationsService.php b/src/Service/OrganizationsService.php index ec7c095..cfb6263 100644 --- a/src/Service/OrganizationsService.php +++ b/src/Service/OrganizationsService.php @@ -229,4 +229,19 @@ class OrganizationsService } } + /* Function that check if the project prefix was provided and if it is unique, if not it will generate a random one and check again until it is unique */ + public function generateUniqueProjectPrefix(): string{ + $prefix = $this->generateRandomPrefix(); + while ($this->entityManager->getRepository(Organizations::class)->findOneBy(['projectPrefix' => $prefix])) { + $prefix = $this->generateRandomPrefix(); + } + return $prefix; + } + + private function generateRandomPrefix(): string + { + return substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 4); + } + + } From f73723f42bbf530d00ce7379f78fe13b1bf96041 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 11 Feb 2026 15:55:17 +0100 Subject: [PATCH 26/29] revert on bug --- src/Controller/ApplicationController.php | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/Controller/ApplicationController.php b/src/Controller/ApplicationController.php index 47d3665..ff7e775 100644 --- a/src/Controller/ApplicationController.php +++ b/src/Controller/ApplicationController.php @@ -101,4 +101,30 @@ class ApplicationController extends AbstractController ]); } + + #[Route(path:'/user/{id}', name: 'user', methods: ['GET'])] + public function getApplicationUsers(int $id): JSONResponse + { + $user = $this->userRepository->find($id); + $actingUser = $this->getUser(); + if (!$user) { + $this->loggerService->logEntityNotFound('User', ['message'=> 'User not found for application list'], $actingUser->getId()); + return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND); + } + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $applications = $this->entityManager->getRepository(Apps::class)->findAll(); + }else{ + $applications = $this->userOrganizationAppService->getUserApplications($user); + + } + $data = array_map(function($app) { + return [ + 'name' => $app->getName(), + 'subDomain' => $app->getSubDomain(), + 'logoMiniUrl' => $this->assetsManager->getUrl($app->getLogoMiniUrl()), + ]; + }, $applications); + + return new JsonResponse($data, Response::HTTP_OK); + } } From 3e06a348ff5849589fbfe5e06de2f5248c4340dc Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 11 Feb 2026 16:16:32 +0100 Subject: [PATCH 27/29] revert on bug --- src/Controller/ActionController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/ActionController.php b/src/Controller/ActionController.php index 0815dc5..1222496 100644 --- a/src/Controller/ActionController.php +++ b/src/Controller/ActionController.php @@ -24,7 +24,7 @@ class ActionController extends AbstractController public function fetchActivitiesAjax(Organizations $organization): JsonResponse { $this->denyAccessUnlessGranted('ROLE_USER'); - if($this->userService->isAdminOfOrganization($organization)){ + if($this->userService->isAdminOfOrganization($organization) || $this->isGranted('ROLE_ADMIN')) { $actions = $this->entityManager->getRepository(Actions::class)->findBy( ['Organization' => $organization], ['date' => 'DESC'], From 2626d27288131c11945549fcef9b450962ae4b4d Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 16 Feb 2026 13:58:15 +0100 Subject: [PATCH 28/29] set up project CRUD --- assets/controllers/organization_controller.js | 2 +- assets/controllers/project_controller.js | 235 ++++++++++++++++++ assets/js/global.js | 19 ++ assets/styles/app.css | 7 + migrations/Version20260211145606.php | 37 +++ migrations/Version20260216092531.php | 32 +++ src/Controller/ApplicationController.php | 16 ++ src/Controller/ProjectController.php | 162 ++++++++++++ src/Entity/Organizations.php | 37 +++ src/Entity/Project.php | 149 +++++++++++ src/Repository/ProjectRepository.php | 61 +++++ src/Service/ProjectService.php | 43 ++++ templates/organization/show.html.twig | 65 ++++- templates/project/index.html.twig | 5 + 14 files changed, 858 insertions(+), 12 deletions(-) create mode 100644 assets/controllers/project_controller.js create mode 100644 migrations/Version20260211145606.php create mode 100644 migrations/Version20260216092531.php create mode 100644 src/Controller/ProjectController.php create mode 100644 src/Entity/Project.php create mode 100644 src/Repository/ProjectRepository.php create mode 100644 src/Service/ProjectService.php create mode 100644 templates/project/index.html.twig diff --git a/assets/controllers/organization_controller.js b/assets/controllers/organization_controller.js index 225c662..a1fc967 100644 --- a/assets/controllers/organization_controller.js +++ b/assets/controllers/organization_controller.js @@ -18,7 +18,7 @@ export default class extends Controller { this.loadActivities(); setInterval(() => { this.loadActivities(); - }, 60000); // Refresh every 60 seconds + }, 300000); // Refresh every 5 minutes } if (this.tableValue && this.sadminValue) { this.table(); diff --git a/assets/controllers/project_controller.js b/assets/controllers/project_controller.js new file mode 100644 index 0000000..bcf2e12 --- /dev/null +++ b/assets/controllers/project_controller.js @@ -0,0 +1,235 @@ +import {Controller} from '@hotwired/stimulus'; +import { Modal } from "bootstrap"; +import {TabulatorFull as Tabulator} from 'tabulator-tables'; +import {eyeIconLink, pencilIcon, TABULATOR_FR_LANG, trashIcon} from "../js/global.js"; + + +export default class extends Controller { + static values = { + listProject : Boolean, + orgId: Number, + admin: Boolean + } + static targets = ["modal", "appList", "nameInput", "formTitle"]; + connect(){ + if(this.listProjectValue){ + this.table(); + } + this.modal = new Modal(this.modalTarget); + } + + table(){ + const columns = [ + {title: "ID ", field: "id", visible: false}, + {title: "Nom du projet ", field: "name", headerFilter: "input", widthGrow: 2, vertAlign: "middle"}, + { + title: "Applications", + field: "applications", + headerSort: false, + hozAlign: "left", + formatter: (cell) => { + const apps = cell.getValue(); + if (!apps || apps.length === 0) { + return "Aucune"; + } + + // Wrap everything in a flex container to keep them on one line + const content = apps.map(app => ` +
+ ${app.name} +
+ `).join(''); + + return `
${content}
`; + } + } + ]; + // 2. Add the conditional column if admin value is true + if (this.adminValue) { + columns.push({ + title: "Base de données", + field: "bddName", + hozAlign: "left", + }, + { + title: "Actions", + field: "id", + width: 120, + hozAlign: "center", + headerSort: false, + formatter: (cell) => { + const id = cell.getValue(); + // Return a button that Stimulus can listen to + return `
+ + +
`; + } + }); + } + + const tabulator = new Tabulator("#tabulator-projectListOrganization", { + langs: TABULATOR_FR_LANG, + locale: "fr", + ajaxURL: `/project/organization/data`, + ajaxConfig: "GET", + pagination: true, + paginationMode: "remote", + paginationSize: 15, + + ajaxParams: {orgId: this.orgIdValue}, + ajaxResponse: (url, params, response) => response, + paginationDataSent: {page: "page", size: "size"}, + paginationDataReceived: {last_page: "last_page"}, + + ajaxSorting: true, + ajaxFiltering: true, + filterMode: "remote", + + ajaxURLGenerator: function(url, config, params) { + let queryParams = new URLSearchParams(); + queryParams.append('orgId', params.orgId); + queryParams.append('page', params.page || 1); + queryParams.append('size', params.size || 15); + + // Add filters + if (params.filter) { + params.filter.forEach(filter => { + queryParams.append(`filter[${filter.field}]`, filter.value); + }); + } + + return `${url}?${queryParams.toString()}`; + }, + rowHeight: 60, + layout: "fitColumns", // activate French + + columns + }) + } + + + async loadApplications() { + try { + const response = await fetch('/application/data/all'); + const apps = await response.json(); + + this.appListTarget.innerHTML = apps.map(app => ` +
+
+ + +
+
+ `).join(''); + } catch (error) { + this.appListTarget.innerHTML = '
Erreur de chargement.
'; + } + } + + async submitForm(event) { + event.preventDefault(); + const formData = new FormData(event.target); + + 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'); + } + + 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) + }); + + if (response.ok) { + this.modal.hide(); + // Use Tabulator's setData() instead of reload() for better UX if possible + location.reload(); + } else { + if (response.status === 409) { + alert("Un projet avec ce nom existe déjà. Veuillez choisir un nom différent."); + } + } + } + + async openEditModal(event) { + const projectId = event.currentTarget.dataset.id; + this.currentProjectId = projectId; + + this.modal.show(); + this.nameInputTarget.disabled = true; + this.formTitleTarget.textContent = "Modifier le projet"; + + try { + // 1. Ensure checkboxes are loaded first + await this.loadApplications(); + + // 2. Fetch the project data + const response = await fetch(`/project/data/${projectId}`); + const project = await response.json(); + + // 3. Set the name + this.nameInputTarget.value = project.name; + + // 4. Check the boxes + // We look for all checkboxes inside our appList target + const checkboxes = this.appListTarget.querySelectorAll('input[type="checkbox"]'); + + checkboxes.forEach(cb => { + cb.checked = project.applications.includes(cb.value); + }); + + } catch (error) { + console.error("Error loading project data", error); + alert("Erreur lors de la récupération des données du projet."); + } + } +// Update your openCreateModal to reset the state + openCreateModal() { + this.currentProjectId = null; + this.modal.show(); + this.nameInputTarget.disabled = false; + this.nameInputTarget.value = ""; + this.formTitleTarget.textContent = "Nouveau Projet"; + this.loadApplications(); + } + + async deleteProject(event) { + const projectId = event.currentTarget.dataset.id; + if (!confirm("Êtes-vous sûr de vouloir supprimer ce projet ?")) { + return; + } + + try { + const response = await fetch(`/project/delete/${projectId}/ajax`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (response.ok) { + location.reload(); + } + }catch (error) { + console.error("Error deleting project", error); + alert("Erreur lors de la suppression du projet."); + } + } +} \ No newline at end of file diff --git a/assets/js/global.js b/assets/js/global.js index 784bef5..80d5c79 100644 --- a/assets/js/global.js +++ b/assets/js/global.js @@ -32,6 +32,25 @@ export function eyeIconLink(url) { `; } +export function pencilIcon() { + return ` + + + + + ` +} + +export function trashIcon(url) { + return ` + + + + + ` +} + + export function deactivateUserIcon() { return ` diff --git a/assets/styles/app.css b/assets/styles/app.css index c819b28..c04f469 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -150,6 +150,13 @@ body { color: var(--primary-blue-dark); } +.color-delete{ + color: var(--delete) !important; +} +.color-delete-dark{ + color: var(--delete-dark); +} + .btn-secondary{ background: var(--secondary); color : #FFFFFF; diff --git a/migrations/Version20260211145606.php b/migrations/Version20260211145606.php new file mode 100644 index 0000000..e916e0f --- /dev/null +++ b/migrations/Version20260211145606.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE project (id SERIAL NOT NULL, organization_id INT NOT NULL, name VARCHAR(255) NOT NULL, applications JSON DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, modified_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_active BOOLEAN NOT NULL, is_deleted BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_2FB3D0EE32C8A3DE ON project (organization_id)'); + $this->addSql('COMMENT ON COLUMN project.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN project.modified_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE project ADD CONSTRAINT FK_2FB3D0EE32C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + 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 CONSTRAINT FK_2FB3D0EE32C8A3DE'); + $this->addSql('DROP TABLE project'); + } +} diff --git a/migrations/Version20260216092531.php b/migrations/Version20260216092531.php new file mode 100644 index 0000000..1082423 --- /dev/null +++ b/migrations/Version20260216092531.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE project ADD bdd_name 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 bdd_name'); + } +} diff --git a/src/Controller/ApplicationController.php b/src/Controller/ApplicationController.php index ff7e775..f273c18 100644 --- a/src/Controller/ApplicationController.php +++ b/src/Controller/ApplicationController.php @@ -127,4 +127,20 @@ class ApplicationController extends AbstractController return new JsonResponse($data, Response::HTTP_OK); } + + #[Route(path: '/data/all', name: 'data_all', methods: ['GET'])] + public function getAllApplications(): JsonResponse + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $applications = $this->entityManager->getRepository(Apps::class)->findAll(); + $data = array_map(function($app) { + return [ + 'id' => $app->getId(), + 'name' => $app->getName(), + 'subDomain' => $app->getSubDomain(), + 'logoMiniUrl' => $this->assetsManager->getUrl($app->getLogoMiniUrl()), + ]; + }, $applications); + return new JsonResponse($data, Response::HTTP_OK); + } } diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php new file mode 100644 index 0000000..f6511b0 --- /dev/null +++ b/src/Controller/ProjectController.php @@ -0,0 +1,162 @@ +render('project/index.html.twig', [ + 'controller_name' => 'ProjectController', + ]); + } + + #[Route('/new/ajax', name: '_new', methods: ['POST'])] + 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); + } + $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); + } + if(!$this->projectService->isApplicationArrayValid($data['applications'])) { + return new JsonResponse(['error' => 'Invalid applications array'], Response::HTTP_BAD_REQUEST); + } + $project = new Project(); + $project->setName($data['name']); + $project->setBddName($sanitizedDbName); + $project->setOrganization($org); + $project->setApplications($data['applications']); + $this->entityManager->persist($project); + $this->entityManager->flush(); + return new JsonResponse(['message' => 'Project created successfully'], Response::HTTP_CREATED); + } + + #[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']]); + if(!$org) { + return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND); + } + $project = $this->projectRepository->findOneBy(['id' => $id]); + if(!$project) { + return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND); + } + $project->setApplications($data['applications']); + $project->setModifiedAt(new \DateTimeImmutable()); + $this->entityManager->persist($project); + $this->entityManager->flush(); + return new JsonResponse(['message' => 'Project updated successfully'], Response::HTTP_OK); + } + + #[Route('/organization/data', name: '_organization_data', methods: ['GET'])] + public function organizationData(Request $request, Packages $assetPackage): JsonResponse { + $this->denyAccessUnlessGranted('ROLE_USER'); + + $page = $request->query->getInt('page', 1); + $size = $request->query->getInt('size', 15); + $filters = $request->query->all('filter'); + $orgId = $request->query->get('orgId'); + + $org = $this->organizationsRepository->findOneBy(['id' => $orgId, 'isDeleted' => false]); + if(!$org) { + return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND); + } + + $paginator = $this->projectRepository->findProjectByOrganization($orgId, $page, $size, $filters); + $total = count($paginator); + + $data = array_map(function (Project $project) use ($assetPackage) { + // Map ONLY the applications linked to THIS specific project + $projectApps = array_map(function($appId) use ($assetPackage) { + // Note: If $project->getApplications() returns IDs, we need to find the entities. + // If your Project entity has a ManyToMany relationship, use $project->getApps() instead. + $appEntity = $this->appsRepository->find($appId); + return $appEntity ? [ + 'id' => $appEntity->getId(), + 'name' => $appEntity->getName(), + 'logoMiniUrl' => $assetPackage->getUrl($appEntity->getLogoMiniUrl()), + ] : null; + }, $project->getApplications() ?? []); + + return [ + 'id' => $project->getId(), + 'name' => ucfirst($project->getName()), + 'applications' => array_filter($projectApps), // Remove nulls + 'bddName' => $project->getBddName(), + 'isActive' => $project->isActive(), + ]; + }, iterator_to_array($paginator)); + + return $this->json([ + 'data' => $data, + 'total' => $total, + 'last_page' => (int)ceil($total / $size), + ]); + } + + #[Route(path: '/data/{id}', name: '_project_data', methods: ['GET'])] + public function projectData(Request $request, int $id): JsonResponse{ + $this->denyAccessUnlessGranted('ROLE_USER'); + $project = $this->projectRepository->findOneBy(['id' => $id]); + if(!$project) { + return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND); + } + return new JsonResponse([ + 'id' => $project->getId(), + 'name' => ucfirst($project->getName()), + 'applications' => $project->getApplications(), + ]); + } + + #[Route(path: '/delete/{id}/ajax', name: '_delete', methods: ['POST'])] + public function delete(int $id): JsonResponse { + $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); + $project = $this->projectRepository->findOneBy(['id' => $id]); + if(!$project) { + return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND); + } + $project->setIsDeleted(true); + $this->entityManager->persist($project); + $this->entityManager->flush(); + return new JsonResponse(['message' => 'Project deleted successfully'], Response::HTTP_OK); + } +} diff --git a/src/Entity/Organizations.php b/src/Entity/Organizations.php index 6cbce6a..470316d 100644 --- a/src/Entity/Organizations.php +++ b/src/Entity/Organizations.php @@ -64,12 +64,19 @@ class Organizations #[ORM\Column(length: 4, nullable: true)] private ?string $projectPrefix = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Project::class, mappedBy: 'organization')] + private Collection $projects; + public function __construct() { $this->apps = new ArrayCollection(); $this->actions = new ArrayCollection(); $this->createdAt = new \DateTimeImmutable(); $this->userOrganizatonApps = new ArrayCollection(); + $this->projects = new ArrayCollection(); } public function getId(): ?int @@ -271,4 +278,34 @@ class Organizations return $this; } + + /** + * @return Collection + */ + public function getProjects(): Collection + { + return $this->projects; + } + + public function addProject(Project $project): static + { + if (!$this->projects->contains($project)) { + $this->projects->add($project); + $project->setOrganization($this); + } + + return $this; + } + + public function removeProject(Project $project): static + { + if ($this->projects->removeElement($project)) { + // set the owning side to null (unless already changed) + if ($project->getOrganization() === $this) { + $project->setOrganization(null); + } + } + + return $this; + } } diff --git a/src/Entity/Project.php b/src/Entity/Project.php new file mode 100644 index 0000000..1512564 --- /dev/null +++ b/src/Entity/Project.php @@ -0,0 +1,149 @@ +createdAt = new \DateTimeImmutable(); + $this->modifiedAt = new \DateTimeImmutable(); + $this->isActive = true; + $this->isDeleted = false; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getOrganization(): ?Organizations + { + return $this->organization; + } + + public function setOrganization(?Organizations $organization): static + { + $this->organization = $organization; + + return $this; + } + + public function getApplications(): ?array + { + return $this->applications; + } + + public function setApplications(?array $applications): static + { + $this->applications = $applications; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getModifiedAt(): ?\DateTimeImmutable + { + return $this->modifiedAt; + } + + public function setModifiedAt(\DateTimeImmutable $modifiedAt): static + { + $this->modifiedAt = $modifiedAt; + + return $this; + } + + public function isActive(): ?bool + { + return $this->isActive; + } + + public function setIsActive(bool $isActive): static + { + $this->isActive = $isActive; + + return $this; + } + + public function isDeleted(): ?bool + { + return $this->isDeleted; + } + + public function setIsDeleted(bool $isDeleted): static + { + $this->isDeleted = $isDeleted; + + return $this; + } + + public function getBddName(): ?string + { + return $this->bddName; + } + + public function setBddName(string $bddName): static + { + $this->bddName = $bddName; + + return $this; + } +} diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php new file mode 100644 index 0000000..9d1d68c --- /dev/null +++ b/src/Repository/ProjectRepository.php @@ -0,0 +1,61 @@ + + */ +class ProjectRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Project::class); + } + + // /** + // * @return Project[] Returns an array of Project objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('p') + // ->andWhere('p.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('p.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Project + // { + // return $this->createQueryBuilder('p') + // ->andWhere('p.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } + public function findProjectByOrganization(int $organizationId, int $page, int $size, array $filters) + { + $qb = $this->createQueryBuilder('p') + ->where('p.organization = :orgId') + ->andWhere('p.isDeleted = :del') + ->setParameter('orgId', $organizationId) + ->setParameter('del', false); + + if (!empty($filters['name'])) { + $qb->andWhere('p.name LIKE :name') + ->setParameter('name', '%' . strtoLower($filters['name']) . '%'); + } + + return $qb->setFirstResult(($page - 1) * $size) + ->setMaxResults($size) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Service/ProjectService.php b/src/Service/ProjectService.php new file mode 100644 index 0000000..11e8d4f --- /dev/null +++ b/src/Service/ProjectService.php @@ -0,0 +1,43 @@ +slug($projectName, '_')->lower()->toString(); +// \d matches any digit character, equivalent to [0-9]. So, the regular expression '/\d/' will match any digit in the string. + $str = preg_replace('/\d/', '', $slug); + return $projectPrefix . '_' . $str; + } + + public function isApplicationArrayValid(array $applicationArray): bool + { + foreach ($applicationArray as $app) { + $app = (int) $app; + if (empty($app) || $app <= 0 || empty($this->appsRepository->findOneBy(['id' => $app]))) { + return false; + } + } + return true; + } +} diff --git a/templates/organization/show.html.twig b/templates/organization/show.html.twig index a24e1b2..8605ad1 100644 --- a/templates/organization/show.html.twig +++ b/templates/organization/show.html.twig @@ -1,6 +1,7 @@ {% extends 'base.html.twig' %} {% block body %} + {% set isSA = is_granted('ROLE_SUPER_ADMIN')%}
{% for type, messages in app.flashes %} {% for message in messages %} @@ -18,7 +19,7 @@

{{ organization.name|title }} - Dashboard

- {% if is_granted("ROLE_SUPER_ADMIN") %} + {% if isSA %} Gérer l'organisation
{# APPLICATION ROW #} - {# TODO: Weird gap not going away #} -
- {% for application in applications %} -
- {% include 'application/appSmall.html.twig' with { - application: application - } %} + {# TODO:remove app acces and replace wioth project overview#} +
+
+

Mes Projets

+ {% if is_granted("ROLE_SUPER_ADMIN") %} + {# Trigger for the Modal #} + + {% endif %} +
+
+
- {% endfor %} +
+ +
{# Activities col #}
-
@@ -122,7 +164,7 @@

Activité récente

-
@@ -148,6 +190,7 @@ + {% endblock %} diff --git a/templates/project/index.html.twig b/templates/project/index.html.twig new file mode 100644 index 0000000..0749a00 --- /dev/null +++ b/templates/project/index.html.twig @@ -0,0 +1,5 @@ +{% extends 'base.html.twig' %} + +{% block body %} + +{% endblock %} From 57e7ec81816aa29bfb3766cee63b336fb22fa033 Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 16 Feb 2026 14:12:31 +0100 Subject: [PATCH 29/29] set up doc for organization --- docs/Organization.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/Organization.md diff --git a/docs/Organization.md b/docs/Organization.md new file mode 100644 index 0000000..1c6243c --- /dev/null +++ b/docs/Organization.md @@ -0,0 +1,35 @@ +# Intro +Each organization are a collection of users and projects. +Users will be able to have multiple organizations and different roles in each of them. For example, a user can be an +admin in one organization and a member in another organization. + +Each organization will have a unique slug that will consist of 4 lowercase letters and this slug will be used for the +database name of each project contained in an organization. + +## Projects +Each project will have a unique name. Each project will be associated with an organization and will have a unique slug +that will come from the organization. The project will have a JSON field that will contain the different applications it has access to + +## Organization Management +The organization management will have different features, such as creating an organization, inviting users to an organization, +managing the roles of users in an organization(admin or not), and deleting an organization. + +### CRUD Operations +- **Create Organization**: Super Admin +- **Read Organization**: Super Admin, Admin and admin of the organization +- **Update Organization**: Super Admin, Admin +- **Delete Organization**: Super Admin + +### User Management +- **Invite User**: Super Admin, Admin and admin of the organization +- **Remove User**: Super Admin, Admin and admin of the organization +- **Change User Role**: Super Admin, Admin and admin of the organization +- **List Users**: Super Admin, Admin and admin of the organization +- **Accept Invitation**: User +- **Decline Invitation**: User + +### Project Management +- **Create Project**: Super Admin +- **Read Project**: Super Admin, Admin and admin of the organization +- **Update Project**: Super Admin +- **Delete Project**: Super Admin