Visualisation Utilisateurs

This commit is contained in:
Charles 2025-08-12 13:39:37 +02:00
parent 2dc5710e06
commit 8a19b01893
9 changed files with 367 additions and 63 deletions

View File

@ -2,13 +2,8 @@
namespace App\Controller;
use App\Entity\Actions;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Form\UserForm;
use App\Entity\UsersOrganizations;
use App\Service\ActionService;
use App\Service\UserOrganizationService;
use App\Service\UserService;
@ -28,8 +23,30 @@ class UserController extends AbstractController
public function __construct(
private readonly UserOrganizationService $userOrganizationService,
private readonly EntityManagerInterface $entityManager,
private readonly UserService $userService, private readonly ActionService $actionService)
{
private readonly UserService $userService,
private readonly ActionService $actionService,
) {
}
#[Route('/', name: 'index', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$usersByOrganization = $this->userService->getUsersGroupedForIndex();
} elseif ($this->isGranted('ROLE_ADMIN')) {
$usersByOrganization = $this->userService->getUsersGroupedForAdmin($user);
} else {
$usersByOrganization = [];
}
return $this->render('user/index.html.twig', [
'usersByOrganization' => $usersByOrganization,
]);
}
}

View File

@ -42,9 +42,19 @@ class Apps
#[ORM\OneToMany(targetEntity: UserOrganizatonApp::class, mappedBy: 'application')]
private Collection $userOrganizatonApps;
/**
* Inverse side of Organization<->Apps ManyToMany.
* Matches Organizations::$apps which is mappedBy "organization".
*
* @var Collection<int, Organizations>
*/
#[ORM\ManyToMany(targetEntity: Organizations::class, inversedBy: 'apps')]
private Collection $organization;
public function __construct()
{
$this->userOrganizatonApps = new ArrayCollection();
$this->organization = new ArrayCollection();
}
public function getId(): ?int
@ -124,7 +134,6 @@ class Apps
return $this;
}
public function getDescriptionSmall(): ?string
{
return $this->descriptionSmall;
@ -158,7 +167,6 @@ class Apps
public function removeUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static
{
if ($this->userOrganizatonApps->removeElement($userOrganizatonApp)) {
// set the owning side to null (unless already changed)
if ($userOrganizatonApp->getApplication() === $this) {
$userOrganizatonApp->setApplication(null);
}
@ -166,4 +174,28 @@ class Apps
return $this;
}
/**
* @return Collection<int, Organizations>
*/
public function getOrganization(): Collection
{
return $this->organization;
}
public function addOrganization(Organizations $organization): static
{
if (!$this->organization->contains($organization)) {
$this->organization->add($organization);
}
return $this;
}
public function removeOrganization(Organizations $organization): static
{
$this->organization->removeElement($organization);
return $this;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Repository;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@ -40,4 +41,42 @@ class UserOrganizatonAppRepository extends ServiceEntityRepository
// ->getOneOrNullResult()
// ;
// }
/**
* Returns organization IDs where the given user is an admin (role name = 'ADMIN')
* for at least one application that the organization has access to.
*
* @return int[]
*/
public function findAdminOrganizationIdsForUser(User $user): array
{
$qb = $this->createQueryBuilder('uoa')
->select('DISTINCT o.id')
->leftJoin('uoa.userOrganization', 'uo')
->leftJoin('uo.organization', 'o')
->leftJoin('uo.users', 'u')
->leftJoin('uoa.role', 'r')
->leftJoin('uoa.application', 'app')
->leftJoin('app.organization', 'ao')
->where('uoa.isActive = :uoaActive')
->andWhere('uo.isActive = :uoActive')
->andWhere('u = :user')
->andWhere('o.isActive = :oActive')
->andWhere('o.isDeleted = :oDeleted')
->andWhere('u.isActive = :uActive')
->andWhere('u.isDeleted = :uDeleted')
->andWhere('r.name = :adminRole')
->andWhere('ao = o')
->setParameter('uoaActive', true)
->setParameter('uoActive', true)
->setParameter('user', $user)
->setParameter('oActive', true)
->setParameter('oDeleted', false)
->setParameter('uActive', true)
->setParameter('uDeleted', false)
->setParameter('adminRole', 'ADMIN');
$rows = $qb->getQuery()->getScalarResult();
return array_map(static fn(array $row) => (int)$row['id'], $rows);
}
}

View File

@ -43,4 +43,52 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
return $queryBuilder->getQuery()->getResult();
}
/**
* Returns active users that are NOT in any UsersOrganizations mapping.
* Returns User entities.
*
* @return User[]
*/
public function findActiveUsersWithoutOrganization(): array
{
$qb = $this->createQueryBuilder('u')
->select('u')
->leftJoin('App\\Entity\\UsersOrganizations', 'uo', 'WITH', 'uo.users = u')
->where('u.isActive = :uActive')
->andWhere('u.isDeleted = :uDeleted')
->andWhere('uo.id IS NULL')
->orderBy('u.surname', 'ASC')
->setParameter('uActive', true)
->setParameter('uDeleted', false);
return $qb->getQuery()->getResult();
}
/**
* Get all active users with their organization relationships
* @return array
*/
public function getAllActiveUsersWithOrganizations(): array
{
$queryBuilder = $this->createQueryBuilder('u')
->select('u', 'uo', 'o')
->leftJoin('App\Entity\UsersOrganizations', 'uo', 'WITH', 'u.id = uo.users')
->leftJoin('App\Entity\Organizations', 'o', 'WITH', 'uo.organization = o.id')
->where('u.isActive = :isActive')
->andWhere('u.isDeleted = :isDeleted')
->andWhere('(uo.isActive = :uoActive OR uo.isActive IS NULL)')
->andWhere('(o.isActive = :oActive OR o.isActive IS NULL)')
->andWhere('(o.isDeleted = :oDeleted OR o.isDeleted IS NULL)')
->orderBy('o.name', 'ASC')
->addOrderBy('u.surname', 'ASC');
$queryBuilder->setParameter('isActive', true);
$queryBuilder->setParameter('isDeleted', false);
$queryBuilder->setParameter('uoActive', true);
$queryBuilder->setParameter('oActive', true);
$queryBuilder->setParameter('oDeleted', false);
return $queryBuilder->getQuery()->getResult();
}
}

View File

@ -2,10 +2,7 @@
namespace App\Repository;
use App\Entity\User;
use App\Entity\UsersOrganizations;
use App\Entity\Organizations;
use App\Entity\Roles;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@ -19,5 +16,65 @@ class UsersOrganizationsRepository extends ServiceEntityRepository
parent::__construct($registry, UsersOrganizations::class);
}
/**
* Returns active user-organization mappings with joined User and Organization.
* Only active and non-deleted users and organizations are included.
*
* @return UsersOrganizations[]
*/
public function findActiveWithUserAndOrganization(): array
{
$qb = $this->createQueryBuilder('uo')
->addSelect('u', 'o')
->leftJoin('uo.users', 'u')
->leftJoin('uo.organization', 'o')
->where('uo.isActive = :uoActive')
->andWhere('u.isActive = :uActive')
->andWhere('u.isDeleted = :uDeleted')
->andWhere('o.isActive = :oActive')
->andWhere('o.isDeleted = :oDeleted')
->orderBy('o.name', 'ASC')
->addOrderBy('u.surname', 'ASC')
->setParameter('uoActive', true)
->setParameter('uActive', true)
->setParameter('uDeleted', false)
->setParameter('oActive', true)
->setParameter('oDeleted', false);
}
return $qb->getQuery()->getResult();
}
/**
* Same as above, filtered by a list of organization IDs.
*
* @param int[] $organizationIds
* @return UsersOrganizations[]
*/
public function findActiveWithUserAndOrganizationByOrganizationIds(array $organizationIds): array
{
if (empty($organizationIds)) {
return [];
}
$qb = $this->createQueryBuilder('uo')
->addSelect('u', 'o')
->leftJoin('uo.users', 'u')
->leftJoin('uo.organization', 'o')
->where('uo.isActive = :uoActive')
->andWhere('u.isActive = :uActive')
->andWhere('u.isDeleted = :uDeleted')
->andWhere('o.isActive = :oActive')
->andWhere('o.isDeleted = :oDeleted')
->andWhere('o.id IN (:orgIds)')
->orderBy('o.name', 'ASC')
->addOrderBy('u.surname', 'ASC')
->setParameter('uoActive', true)
->setParameter('uActive', true)
->setParameter('uDeleted', false)
->setParameter('oActive', true)
->setParameter('oDeleted', false)
->setParameter('orgIds', $organizationIds);
return $qb->getQuery()->getResult();
}
}

View File

@ -3,9 +3,8 @@
namespace App\Service;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use Doctrine\ORM\EntityManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
@ -61,4 +60,95 @@ class UserService
return false;
}
/**
* Returns UsersOrganizations rows joined with User and Organization, grouped by organization name.
* @return array<string, array{ organization: ?object, users: array<int, array{ users: User, userOrganization: UsersOrganizations, is_connected: bool }> }>
*/
private function groupUserOrganizationsByOrganizationFromRows(array $rows): array
{
$grouped = [];
foreach ($rows as $userOrg) {
$organization = $userOrg->getOrganization();
$user = $userOrg->getUsers();
$orgName = $organization?->getName() ?? 'No Organization';
if (!isset($grouped[$orgName])) {
$grouped[$orgName] = [
'organization' => $organization,
'users' => [],
];
}
$grouped[$orgName]['users'][] = [
'users' => $user,
'userOrganization' => $userOrg,
'is_connected' => $this->isUserConnected($user->getEmail()),
];
}
return $grouped;
}
/**
* Returns users that have no UsersOrganizations mapping.
* @return array<int, array{ users: User, userOrganization: null, is_connected: bool }>
*/
private function buildUsersWithoutOrganization(): array
{
$userRepository = $this->entityManager->getRepository(User::class);
$users = $userRepository->findActiveUsersWithoutOrganization();
$list = [];
foreach ($users as $user) {
$list[] = [
'users' => $user,
'userOrganization' => null,
'is_connected' => $this->isUserConnected($user->getEmail()),
];
}
return $list;
}
/**
* Public API: build the final structure for the index view.
* Groups users by organization and adds a "No Organization" group for users without mapping.
* @return array<string, array{ organization: ?object, users: array<int, array{ users: User, userOrganization: UsersOrganizations|null, is_connected: bool }> }>
*/
public function getUsersGroupedForIndex(): array
{
$usersOrganizationsRepo = $this->entityManager->getRepository(UsersOrganizations::class);
$rows = $usersOrganizationsRepo->findActiveWithUserAndOrganization();
$grouped = $this->groupUserOrganizationsByOrganizationFromRows($rows);
$noOrgUsers = $this->buildUsersWithoutOrganization();
if (!empty($noOrgUsers)) {
$grouped['No Organization'] = [
'organization' => null,
'users' => $noOrgUsers,
];
}
ksort($grouped);
return $grouped;
}
/**
* For admins: return users grouped only for organizations where the given user is ADMIN.
* @return array<string, array{ organization: ?object, users: array<int, array{ users: User, userOrganization: UsersOrganizations, is_connected: bool }> }>
*/
public function getUsersGroupedForAdmin(User $adminUser): array
{
$uoaRepo = $this->entityManager->getRepository(UserOrganizatonApp::class);
$orgIds = $uoaRepo->findAdminOrganizationIdsForUser($adminUser);
$uoRepo = $this->entityManager->getRepository(UsersOrganizations::class);
$rows = $uoRepo->findActiveWithUserAndOrganizationByOrganizationIds($orgIds);
$grouped = $this->groupUserOrganizationsByOrganizationFromRows($rows);
ksort($grouped);
return $grouped;
}
}

View File

@ -37,23 +37,23 @@
</a>
</li>
<li class="nav-item">
{% if is_granted('ROLE_SUPER_ADMIN') %}
<a class="nav-link" href="{{ path('organization_index') }}">
<i class="icon-grid menu-icon"> {{ ux_icon('bi:buildings', {height: '15px', width: '15px'}) }}
</i>
<span class="menu-title">
Organizations</span>
</a>
{% elseif is_granted('ROLE_ADMIN') %}
<a class="nav-link" href="{{ path('organization_index') }}">
<i class="icon-grid menu-icon">
{{ ux_icon('bi:building', {height: '15px', width: '15px'}) }}
</i>
<span class="menu-title">
Organization
</span>
</a>
{% endif %}
{# {% if is_granted('ROLE_SUPER_ADMIN') %}#}
{# <a class="nav-link" href="{{ path('organization_index') }}">#}
{# <i class="icon-grid menu-icon"> {{ ux_icon('bi:buildings', {height: '15px', width: '15px'}) }}#}
{# </i>#}
{# <span class="menu-title">#}
{# Organizations</span>#}
{# </a>#}
{# {% elseif is_granted('ROLE_ADMIN') %}#}
{# <a class="nav-link" href="{{ path('organization_index') }}">#}
{# <i class="icon-grid menu-icon">#}
{# {{ ux_icon('bi:building', {height: '15px', width: '15px'}) }}#}
{# </i>#}
{# <span class="menu-title">#}
{# Organization#}
{# </span>#}
{# </a>#}
{# {% endif %}#}
</li>
{% endif %}
</ul>

View File

@ -6,13 +6,31 @@
<div class="w-100 h-100 p-5 m-auto" data-controller="user">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Gestion Utilisateurs</h1>
<a href="{{ path('user_new') }}" class="btn btn-primary">Ajouter un utilisateur</a>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<a href="{{ path('user_new') }}" class="btn btn-primary">Ajouter un utilisateur</a>
{% endif %}
</div>
{% for org in usersByOrganization %}
{% include 'user/userList.html.twig' with {
title: org.organization_name|capitalize
} %}
{% endfor %}
{% if is_granted('ROLE_SUPER_ADMIN') or is_granted('ROLE_ADMIN') %}
{% if usersByOrganization|length == 0 %}
<div class="alert alert-info">
<h4>Aucun utilisateur trouvé</h4>
<p>Il n'y a actuellement aucun utilisateur correspondant à votre périmètre.</p>
</div>
{% else %}
{% for orgName, orgData in usersByOrganization %}
{% include 'user/userList.html.twig' with {
title: orgName,
org: orgData,
organizationId: orgData.organization ? orgData.organization.id : null
} %}
{% endfor %}
{% endif %}
{% else %}
<div class="alert alert-warning">
<h4>Accès limité</h4>
<p>Vous n'avez pas les permissions nécessaires pour voir la liste des utilisateurs.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -5,7 +5,9 @@
{% if title is defined %}
<div class="card-title d-flex justify-content-between align-items-center ">
<h3>{{ title }}</h3>
{% if organizationId %}
{# <span class="badge bg-primary">ID: {{ organizationId }}</span>#}
{% endif %}
</div>
{% endif %}
<div class="card-body">
@ -22,46 +24,47 @@
</tr>
</thead>
<tbody>
{% if org|length == 0 %}
<tr>
<td colspan="6" class="text-center">Aucun utilisateur trouvé.</td>
</tr>
{% elseif org.users|length == 0 %}
{% if org.users|length == 0 %}
<tr>
<td colspan="6" class="text-center">Aucun utilisateur trouvé.</td>
<td colspan="6" class="text-center">Aucun utilisateur trouvé dans cette organisation.</td>
</tr>
{% else %}
{% for user in org.users %}
{% for userData in org.users %}
<tr>
<td>
{% if user.users.pictureUrl %}
<img src="{{ asset(user.users.pictureUrl) }}" alt="User profile pic"
{% if userData.users.pictureUrl %}
<img src="{{ asset(userData.users.pictureUrl) }}" alt="User profile pic"
class="rounded-circle"
style="width:40px; height:40px;">
{% else %}
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
style="width:40px; height:40px;">
<span class="text-white">{{ userData.users.name|first|upper }}{{ userData.users.surname|first|upper }}</span>
</div>
{% endif %}
</td>
<td>{{ user.users.surname }}</td>
<td>{{ user.users.name }}</td>
<td>{{ user.users.email }}</td>
<td>{{ userData.users.surname }}</td>
<td>{{ userData.users.name }}</td>
<td>{{ userData.users.email }}</td>
<td>
{% if user.is_connected %}
{% if userData.is_connected %}
<span class="badge bg-success">Actif</span>
{% else %}
<span class="badge bg-secondary">Inactif</span>
{% endif %}
</td>
<td>
{% if organizationId is defined %}
<a href="{{ path('user_show', {'id': user.users.id, 'organizationId': organizationId}) }}"
class="p-3 align-middle color-primary">
{{ ux_icon('fa6-regular:eye', {height: '30px', width: '30px'}) }}
</a>
{% else %}
<a href="{{ path('user_show', {'id': user.users.id}) }}"
class="p-3 align-middle color-primary">
{{ ux_icon('fa6-regular:eye', {height: '30px', width: '30px'}) }}
</a>
{% endif %}
{# {% if organizationId is defined and organizationId %}#}
{# <a href="{{ path('user_show', {'id': userData.users.id, 'organizationId': organizationId}) }}"#}
{# class="p-3 align-middle color-primary">#}
{# {{ ux_icon('fa6-regular:eye', {height: '30px', width: '30px'}) }}#}
{# </a>#}
{# {% else %}#}
{# <a href="{{ path('user_show', {'id': userData.users.id}) }}"#}
{# class="p-3 align-middle color-primary">#}
{# {{ ux_icon('fa6-regular:eye', {height: '30px', width: '30px'}) }}#}
{# </a>#}
{# {% endif %}#}
</td>
</tr>
{% endfor %}