Merge branch 'dev/org/feature' into 'develop'

Add/Remove admin from orgs

See merge request easy-solutions/apps/easyportal!32
This commit is contained in:
Charles-Edouard MARGUERITE 2026-02-16 15:03:49 +00:00
commit 3765b8c314
5 changed files with 261 additions and 31 deletions

View File

@ -1,7 +1,15 @@
import {Controller} from '@hotwired/stimulus';
import Choices from 'choices.js';
import {TabulatorFull as Tabulator} from 'tabulator-tables';
import {activateUserIcon, deactivateUserIcon, eyeIconLink, sendEmailIcon, TABULATOR_FR_LANG} from "../js/global.js";
import {
activateUserIcon,
deactivateUserIcon,
eyeIconLink,
sendEmailIcon,
TABULATOR_FR_LANG,
trashIconForm
} from "../js/global.js";
import { Modal } from "bootstrap";
export default class extends Controller {
@ -18,7 +26,7 @@ export default class extends Controller {
orgId: Number
}
static targets = ["select", "statusButton"];
static targets = ["select", "statusButton", "modal", "userSelect"];
connect() {
this.roleSelect();
@ -34,6 +42,9 @@ export default class extends Controller {
if (this.listOrganizationValue) {
this.tableOrganization()
}
if (this.hasModalTarget) {
this.modal = new Modal(this.modalTarget);
}
}
@ -499,8 +510,9 @@ export default class extends Controller {
headerSort: false,
formatter: (cell) => {
const url = cell.getValue();
const orgId = this.orgIdValue;
if (url) {
eyeIconLink(url);
return trashIconForm(url, orgId);
}
return '';
}
@ -918,4 +930,92 @@ export default class extends Controller {
btn.dataset.active = "false";
}
}
async openAddAdminModal() {
this.modal.show();
await this.loadAvailableUsers();
}
async loadAvailableUsers() {
try {
const response = await fetch(`/organization/${this.orgIdValue}/users`);
const data = await response.json();
if (data.users) {
this.userSelectTarget.innerHTML = '<option value="">Choisir un utilisateur...</option>' +
data.users.map(user => `
<option value="${user.id}">${user.email}</option>
`).join('');
}
} catch (error) {
this.userSelectTarget.innerHTML = '<option value="">Erreur de chargement</option>';
}
}
async submitAddAdmin(event) {
event.preventDefault();
const formData = new FormData(event.target);
const userId = formData.get('userId');
try {
const response = await fetch(`/user/organization/admin/${userId}`, {
method: 'POST',
body: formData, // Sends userId, status="add", and organizationId
headers: {'X-Requested-With': 'XMLHttpRequest'}
});
if (response.ok) {
this.modal.hide();
// If you use Tabulator, refresh it here
// e.g., this.table.setData();
location.reload();
} else {
if (response.status === 409) {
alert("Cet utilisateur est déjà administrateur de l'organisation.");
return;
}
alert("Erreur lors de l'ajout de l'administrateur.");
}
} catch (error) {
alert("Une erreur est survenue.");
}
}
async removeAdmin(event) {
// 1. Prevent any default behavior
event.preventDefault();
const button = event.currentTarget;
const url = button.dataset.url;
const organizationId = button.dataset.orgId;
if (!confirm("Voulez-vous vraiment retirer les droits d'administrateur à cet utilisateur ?")) {
return;
}
// 2. Prepare the payload (matching your previous hidden fields)
const formData = new FormData();
formData.append('status', 'remove');
formData.append('organizationId', organizationId);
try {
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.ok) {
// 3. Success! Reload the page to see the updated list
location.reload();
} else {
const errorData = await response.json();
alert("Erreur: " + (errorData.error || "Impossible de supprimer les droits."));
}
} catch (error) {
alert("Une erreur réseau est survenue.");
}
}
}

View File

@ -41,7 +41,7 @@ export function pencilIcon() {
`
}
export function trashIcon(url) {
export function trashIcon() {
return `
<span class="align-middle color-delete">
<svg xmlns="http://www.w3.org/2000/svg" width="35px" height="35px" viewBox="0 0 24 24">
@ -50,6 +50,18 @@ export function trashIcon(url) {
`
}
export function trashIconForm(url, organizationId) {
return `
<button class="btn btn-link p-0 border-0 color-delete align-middle"
data-action="click->user#removeAdmin"
data-url="${url}"
data-org-id="${organizationId}">
<svg xmlns="http://www.w3.org/2000/svg" width="35px" height="35px" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1l-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/></svg>
</button>`;
}
export function deactivateUserIcon() {
return `<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 640 512">

View File

@ -10,6 +10,7 @@ use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Form\OrganizationForm;
use App\Repository\OrganizationsRepository;
use App\Repository\UsersOrganizationsRepository;
use App\Service\ActionService;
use App\Service\AwsService;
use App\Service\LoggerService;
@ -43,7 +44,7 @@ class OrganizationController extends AbstractController
private readonly ActionService $actionService,
private readonly UserOrganizationService $userOrganizationService,
private readonly OrganizationsRepository $organizationsRepository,
private readonly LoggerService $loggerService)
private readonly LoggerService $loggerService, private readonly UsersOrganizationsRepository $usersOrganizationsRepository)
{
}
@ -323,4 +324,31 @@ class OrganizationController extends AbstractController
'total' => $total,
]);
}
#[Route(path: '/{id}/users', name: 'users', methods: ['GET'])]
public function users($id): JsonResponse{
$this->denyAccessUnlessGranted("ROLE_USER");
$actingUser = $this->getUser();
$organization = $this->organizationsRepository->find($id);
if (!$organization) {
$this->loggerService->logEntityNotFound('Organization', [
'org_id' => $id,
'message' => 'Organization not found for users endpoint'
], $actingUser->getUserIdentifier());
return $this->json(['error' => self::NOT_FOUND], Response::HTTP_NOT_FOUND);
}
if (!$this->userService->isAdminOfOrganization($organization) && !$this->isGranted("ROLE_ADMIN")) {
$this->loggerService->logAccessDenied($actingUser->getUserIdentifier());
return $this->json(['error' => self::ACCESS_DENIED], Response::HTTP_FORBIDDEN);
}
$uos = $this->usersOrganizationsRepository->findBy(['organization' => $organization, 'isActive' => true]);
$users = array_map(function (UsersOrganizations $uo) {
$user = $uo->getUsers();
return [
'id' => $user->getId(),
'email' => $user->getEmail()
];
}, $uos);
return $this->json(['users' => $users]);
}
}

View File

@ -603,30 +603,24 @@ class UserController extends AbstractController
$this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getUserIdentifier());
throw $this->createNotFoundException(self::NOT_FOUND);
}
if ($this->userService->isAdminOfOrganization($org) || $this->isGranted("ROLE_ADMIN")) {
$uos = $this->uoRepository->findBy(['organization' => $org]);
if ($this->userService->isAdminOfOrganization($org) || $this->isGranted("ROLE_ADMIN")){
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
$users = [];
foreach ($uos as $uo) {
if ($this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, 'role' => $roleAdmin])) {
$users[] = $uo;
}
}
// Map to array (keep isConnected)
$data = array_map(function (UsersOrganizations $uo) {
$user = $uo->getUsers();
if (!$roleAdmin) {
$this->loggerService->logEntityNotFound('Role', ['name' => 'ADMIN'], $actingUser->getUserIdentifier());
throw $this->createNotFoundException(self::NOT_FOUND);
}
$uos = $this->uoRepository->findBy(['organization' => $org, 'role' => $roleAdmin, 'statut' => "ACCEPTED", 'isActive' => true]);
$data = array_map(function (UsersOrganizations $uos) {
$user = $uos->getUsers();
$initials = $user->getName()[0] . $user->getSurname()[0];
return [
'pictureUrl' => $user->getPictureUrl(),
'email' => $user->getEmail(),
'isConnected' => $this->userService->isUserConnected($user->getUserIdentifier()),
'showUrl' => $this->generateUrl('user_show', ['id' => $user->getId()]),
'showUrl' => $this->generateUrl('user_organization_admin_status', ['id' => $user->getId()]),
'initials' => strtoupper($initials),
];
}, $users);
}, $uos);
return $this->json([
'data' => $data,
]);
@ -774,5 +768,70 @@ class UserController extends AbstractController
}
return $this->render('security/login.html.twig', ['error'=> null, 'last_username' => $user->getEmail()]);
}
#[Route(path: '/organization/admin/{id}', name: 'organization_admin_status', methods: ['POST'])]
public function organizationAdminStatus(int $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$actingUser = $this->getUser();
$user = $this->userRepository->find($id);
if (!$user) {
$this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getUserIdentifier());
throw $this->createNotFoundException(self::NOT_FOUND);
}
$orgId = $request->request->get('organizationId');
$org = $this->organizationRepository->find($orgId);
if (!$org) {
$this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getUserIdentifier());
throw $this->createNotFoundException(self::NOT_FOUND);
}
$uo = $this->uoRepository->findOneBy(['users' => $user,'organization' => $org]);
if (!$uo) {
$this->loggerService->logEntityNotFound('UsersOrganization', ['user_id' => $id, 'organization_id' => $uo->getOrganization()->getId()], $actingUser->getUserIdentifier());
throw $this->createNotFoundException(self::NOT_FOUND);
}
$status = $request->request->get('status');
if ($status === 'add') {
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
if (!$roleAdmin) {
$this->loggerService->logEntityNotFound('Role', ['name' => 'ADMIN'], $actingUser->getUserIdentifier());
throw $this->createNotFoundException(self::NOT_FOUND);
}
if ($uo->getRole() == $roleAdmin) {
return new JsonResponse(['error' => 'User is already admin'], Response::HTTP_CONFLICT);
}
$uo->setRole($roleAdmin);
$uo->setModifiedAt(new \DateTimeImmutable());
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->loggerService->logOrganizationInformation($uo->getOrganization()->getId(), $actingUser->getUserIdentifier(), "Role ADMIN added to user with uo id : {$uo->getId()}");
$this->actionService->createAction("Grant organization admin rights", $actingUser, $uo->getOrganization(), "for user " . $uo->getUSers()->getUserIdentifier());
return new JsonResponse(['status' => 'added'], Response::HTTP_OK);
}
if ($status === 'remove') {
$roleUser = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'USER']);
if (!$roleUser) {
$this->loggerService->logEntityNotFound('Role', ['name' => 'ADMIN'], $actingUser->getUserIdentifier());
throw $this->createNotFoundException(self::NOT_FOUND);
}
$uo->setRole($roleUser);
$uo->setModifiedAt(new \DateTimeImmutable());
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->loggerService->logOrganizationInformation($uo->getOrganization()->getId(), $actingUser->getUserIdentifier(), "Role ADMIN removed from user with uo id : {$uo->getId()}");
$this->actionService->createAction("Revoke organization admin rights", $actingUser, $uo->getOrganization(), "for user " . $uo->getUSers()->getUserIdentifier());
return new JsonResponse(['status' => 'removed'], Response::HTTP_OK);
}
//invalid status
$this->loggerService->logError('Invalid status provided for organizationAdminStatus', [
'requested_status' => $status,
'target_user_id' => $uo->getUsers()->getId(),
'organization_id' => $uo->getOrganization()->getId(),
]);
throw $this->createNotFoundException(self::NOT_FOUND);
}
}

View File

@ -69,18 +69,49 @@
</div>
</div>
</div>
<div class="col mb-3 card no-header-bg">
<div class="card-header">
<h2>
Administrateurs
</h2>
<div class="col mb-3 card no-header-bg"
data-controller="user"
data-user-org-id-value="{{ organization.id }}"
data-user-admin-value="true"
data-user-list-small-value="true">
<div class="card-header d-flex justify-content-between align-items-center">
<h2>Administrateurs</h2>
<button type="button" class="btn btn-primary" data-action="click->user#openAddAdminModal">
Ajouter un administrateur
</button>
</div>
<div class="card-body">
<div id="tabulator-userListSmallAdmin" data-controller="user"
data-user-aws-value="{{ aws_url }}"
data-user-admin-value="true"
data-user-list-small-value="true"
data-user-org-id-value="{{ organization.id }}">
<div id="tabulator-userListSmallAdmin"></div>
</div>
{# Modal for Adding Admin #}
<div class="modal fade" id="addAdminModal" tabindex="-1" aria-hidden="true" data-user-target="modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ajouter un administrateur</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form data-action="submit->user#submitAddAdmin">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Sélectionner l'utilisateur</label>
<select name="userId" class="form-select" data-user-target="userSelect" required>
<option value="">Chargement...</option>
</select>
</div>
{# Hidden Fields #}
<input type="hidden" name="status" value="add">
<input type="hidden" name="organizationId" value="{{ organization.id }}">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="submit" class="btn btn-primary">Ajouter</button>
</div>
</form>
</div>
</div>
</div>
</div>