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

Dev/api/feature

See merge request easy-solutions/apps/easyportal!33
This commit is contained in:
Charles-Edouard MARGUERITE 2026-02-25 09:19:51 +00:00
commit 698caebaea
47 changed files with 1750 additions and 520 deletions

3
.env
View File

@ -48,6 +48,8 @@ OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.key
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.key
OAUTH_PASSPHRASE=8170ea18d2e3e05b5c7ae0672a754bf4
OAUTH_ENCRYPTION_KEY=f1b7c279f7992205a0df45e295d07066
OAUTH_SSO_IDENTIFIER='sso-own-identifier'
OAUTH_SSO_IDENTIFIER_LOGIN='sso-own-identifier'
###< league/oauth2-server-bundle ###
###> nelmio/cors-bundle ###
@ -72,3 +74,4 @@ AWS_ENDPOINT=https://s3.amazonaws.com
AWS_S3_PORTAL_URL=https://s3.amazonaws.com/portal
###< aws/aws-sdk-php-symfony ###
APP_URL='https://example.com'
APP_DOMAIN='example.com'

View File

@ -0,0 +1,27 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
async fetchAndRenderApplications(targetElement) {
try {
const response = await fetch('/application/data/all');
const apps = await response.json();
targetElement.innerHTML = apps.map(app => `
<div class="col-md-4 mb-3">
<div class="form-check border p-2 rounded d-flex align-items-center gap-2">
<input class="form-check-input ms-1" type="checkbox" name="applications[]" value="${app.id}" id="app_${app.id}">
<label class="form-check-label d-flex align-items-center gap-2" for="app_${app.id}">
<img src="${app.logoMiniUrl}" alt="${app.name}" style="height: 20px; width: 20px; object-fit: contain;">
${app.name}
</label>
</div>
</div>
`).join('');
return apps;
} catch (error) {
targetElement.innerHTML = '<div class="text-danger">Erreur de chargement.</div>';
console.error("App load error:", error);
}
}
}

View File

@ -10,7 +10,7 @@ export default class extends Controller {
orgId: Number,
admin: Boolean
}
static targets = ["modal", "appList", "nameInput", "formTitle"];
static targets = ["modal", "appList", "nameInput", "formTitle", "timestampSelect", "deletionSelect"];
connect(){
if(this.listProjectValue){
this.table();
@ -137,17 +137,23 @@ export default class extends Controller {
async submitForm(event) {
event.preventDefault();
const formData = new FormData(event.target);
const form = event.target;
const formData = new FormData(form); // This automatically picks up the 'logo' file
const payload = {
organizationId: this.orgIdValue,
applications: formData.getAll('applications[]')
};
// Only include name if it wasn't disabled (new projects)
if (!this.nameInputTarget.disabled) {
payload.name = formData.get('name');
// 1. Validate File Format
const logoFile = formData.get('logo');
if (logoFile && logoFile.size > 0) {
const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg'];
if (!allowedTypes.includes(logoFile.type)) {
alert("Format invalide. Veuillez utiliser uniquement des fichiers PNG ou JPG.");
return; // Stop submission
}
}
// 2. Prepare for Multipart sending
// Since we are using FormData, we don't need JSON.stringify or 'Content-Type': 'application/json'
// We add the extra fields to the formData object
formData.append('organizationId', this.orgIdValue);
const url = this.currentProjectId
? `/project/edit/${this.currentProjectId}/ajax`
@ -155,17 +161,20 @@ export default class extends Controller {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
// IMPORTANT: Do NOT set Content-Type header when sending FormData with files
// The browser will set 'multipart/form-data' and the boundary automatically
body: formData
});
if (response.ok) {
this.modal.hide();
// Use Tabulator's setData() instead of reload() for better UX if possible
location.reload();
} else {
const result = await response.json();
if (response.status === 409) {
alert("Un projet avec ce nom existe déjà. Veuillez choisir un nom différent.");
alert("Un projet avec ce nom existe déjà.");
} else {
alert(result.error || "Une erreur est survenue.");
}
}
}
@ -188,6 +197,9 @@ export default class extends Controller {
// 3. Set the name
this.nameInputTarget.value = project.name;
console.log(project);
this.timestampSelectTarget.value = project.timestampPrecision;
this.deletionSelectTarget.value = project.deletionAllowed;
// 4. Check the boxes
// We look for all checkboxes inside our appList target

View File

@ -10,9 +10,10 @@ import {
trashIconForm
} from "../js/global.js";
import { Modal } from "bootstrap";
import base_controller from "./base_controller.js";
export default class extends Controller {
export default class extends base_controller {
static values = {
rolesArray: Array,
selectedRoleIds: Array,
@ -26,7 +27,7 @@ export default class extends Controller {
orgId: Number
}
static targets = ["select", "statusButton", "modal", "userSelect"];
static targets = ["select", "statusButton", "modal", "userSelect", "appList", "emailInput", "phoneInput", "nameInput", "surnameInput"];
connect() {
this.roleSelect();
@ -1018,4 +1019,105 @@ export default class extends Controller {
alert("Une erreur réseau est survenue.");
}
}
async openNewUserModal() {
this.modal.show();
// Call the shared logic and pass the target
await this.fetchAndRenderApplications(this.appListTarget);
}
async submitNewUser(event) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
const ucSurname = formData.get('surname').toUpperCase();
formData.set('surname', ucSurname);
try {
const response = await fetch('/user/new/ajax', { // Adjust path if prefix is different
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const result = await response.json();
if (response.ok) {
this.modal.hide();
form.reset(); // Clear the form
location.reload();
} else {
alert("Erreur: " + (result.error || "Une erreur est survenue lors de la création."));
}
} catch (error) {
alert("Erreur réseau.");
}
}
async openEditUserModal(event) {
const userId = event.currentTarget.dataset.id;
this.currentUserId = userId;
this.modal.show();
try {
// 1. Fetch all available apps using your shared base method
await this.fetchAndRenderApplications(this.appListTarget);
// 2. Fetch specific user data WITH the orgId query parameter
// We use this.orgIdValue which is mapped to data-user-org-id-value
const response = await fetch(`/user/data/${userId}?orgId=${this.orgIdValue}`);
if (!response.ok) throw new Error('Failed to fetch user data');
const user = await response.json();
// 3. Fill text inputs
this.emailInputTarget.value = user.email;
this.phoneInputTarget.value = user.phoneNumber || '';
this.nameInputTarget.value = user.name;
this.surnameInputTarget.value = user.surname;
// 4. Check the application boxes
const checkboxes = this.appListTarget.querySelectorAll('input[type="checkbox"]');
// Ensure we handle IDs as strings or numbers consistently
const activeAppIds = user.applicationIds.map(id => id.toString());
checkboxes.forEach(cb => {
cb.checked = activeAppIds.includes(cb.value.toString());
});
} catch (error) {
console.error("Erreur lors du chargement des données utilisateur:", error);
alert("Impossible de charger les informations de l'utilisateur.");
}
}
async submitEditUser(event) {
event.preventDefault();
const formData = new FormData(event.target);
// Force Uppercase on Surname as requested
formData.set('surname', formData.get('surname').toUpperCase());
try {
const response = await fetch(`/user/edit/${this.currentUserId}/ajax`, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (response.ok) {
this.modal.hide();
location.reload();
} else {
const result = await response.json();
alert(result.error || "Erreur lors de la mise à jour.");
}
} catch (error) {
alert("Erreur réseau.");
}
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/></svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path fill-rule="evenodd" d="M5 2a.5.5 0 0 1 .5-.5c.862 0 1.573.287 2.06.566c.174.099.321.198.44.286c.119-.088.266-.187.44-.286A4.17 4.17 0 0 1 10.5 1.5a.5.5 0 0 1 0 1c-.638 0-1.177.213-1.564.434a3.5 3.5 0 0 0-.436.294V7.5H9a.5.5 0 0 1 0 1h-.5v4.272c.1.08.248.187.436.294c.387.221.926.434 1.564.434a.5.5 0 0 1 0 1a4.17 4.17 0 0 1-2.06-.566A5 5 0 0 1 8 13.65a5 5 0 0 1-.44.285a4.17 4.17 0 0 1-2.06.566a.5.5 0 0 1 0-1c.638 0 1.177-.213 1.564-.434c.188-.107.335-.214.436-.294V8.5H7a.5.5 0 0 1 0-1h.5V3.228a3.5 3.5 0 0 0-.436-.294A3.17 3.17 0 0 0 5.5 2.5A.5.5 0 0 1 5 2"/><path d="M10 5h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4v1h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4zM6 5V4H2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4v-1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/></g></svg>

After

Width:  |  Height:  |  Size: 827 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><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>

After

Width:  |  Height:  |  Size: 609 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm0-2h16V6H4zm0 0V6zm8-5h6q.425 0 .713-.288T19 12V8q0-.425-.288-.712T18 7h-6q-.425 0-.712.288T11 8v4q0 .425.288.713T12 13m1-2V9h4v2z"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@ -22,7 +22,7 @@ export const TABULATOR_FR_LANG = {
};
export function eyeIconLink(url) {
return `<a href="${url}" class="p-3 align-middle color-primary" title="Accéder au profil" target="_blank">
return `<a href="${url}" class="p-3 align-middle color-primary" title="Accéder au profil" >
<svg xmlns="http://www.w3.org/2000/svg"
width="35px"
height="35px"

View File

@ -19,6 +19,10 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api_token_validation:
pattern: ^/api/validate-token
stateless: true
oauth2: true
oauth_userinfo:
pattern: ^/oauth2/userinfo
stateless: true
@ -30,6 +34,10 @@ security:
auth_token:
pattern: ^/token
stateless: true
api_m2m:
pattern: ^/api/v1/
stateless: true
oauth2: true
api:
pattern: ^/oauth/api
security: true
@ -65,6 +73,7 @@ security:
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/api/validate-token, roles: PUBLIC_ACCESS }
- { path: ^/password_setup, roles: PUBLIC_ACCESS }
- { path: ^/password_reset, roles: PUBLIC_ACCESS }
- { path: ^/sso_logout, roles: IS_AUTHENTICATED_FULLY }
@ -76,8 +85,6 @@ security:
- { path: ^/oauth2/userinfo, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/, roles: ROLE_USER }
when@test:
security:
password_hashers:

View File

@ -8,8 +8,11 @@ parameters:
aws_public_url: '%env(AWS_ENDPOINT)%'
aws_bucket: '%env(S3_PORTAL_BUCKET)%'
app_url: '%env(APP_URL)%'
app_domain: '%env(APP_DOMAIN)%'
mercure_secret: '%env(MERCURE_JWT_SECRET)%'
logos_directory: '%kernel.project_dir%/public/uploads/logos'
oauth_sso_identifier: '%env(OAUTH_SSO_IDENTIFIER)%'
oauth_sso_identifier_login: '%env(OAUTH_SSO_IDENTIFIER_LOGIN)%'
services:
# default configuration for services in *this* file
@ -28,9 +31,16 @@ services:
App\MessageHandler\NotificationMessageHandler:
arguments:
$appUrl: '%app_url%'
App\Service\SSO\ProjectService:
arguments:
$appUrl: '%app_url%'
$clientIdentifier: '%oauth_sso_identifier%'
App\EventSubscriber\:
resource: '../src/EventSubscriber/'
tags: ['kernel.event_subscriber']
App\EventSubscriber\LoginSubscriber:
arguments:
$clientIdentifier: '%oauth_sso_identifier_login%'
App\Service\AwsService:
arguments:
$awsPublicUrl: '%aws_public_url%'

225
docs/API.md Normal file
View File

@ -0,0 +1,225 @@
# Intro
The Api made some changes to the current structure of the project. These changes include the following:
- **Security** : the security.yaml file has been updated both on the server and the client side
- **Controllers**: Controllers need to be updated on the client side
- **Services**: Services and token management need updates on the client side
- **Health**: I now want to commit a Talon E over a wall IRL
- **Entities**: Entities need to be updated on the client side to include a new field (sso_id).
- This field will be used a lot, so add it to an entity if you are going to work with the API and the SSO. PLEASE.
- **Roles**: A new role was added because we are doing M2M. Only God know how that work now, but it works, so I might start praying from now on.
# Security
new firewall was added. Keep the same structure for future firewalls.
### Client side
```yaml
api_project:
pattern: ^/api/v1/project #ofc, this is an example, please THINK and change the name
stateless: true
access_token:
token_handler: App\Security\SsoTokenHandler
```
Same thing, new firewall was added.
### Server side
```yaml
api_token_validation:
pattern: ^/api/validate-token #this is NOT an example. DON'T change or it will all go to sh.t
stateless: true
oauth2: true
```
```yaml
# A rajouter dans l'access_control aussi !!! IMPORTANT !!!
- { path: ^/api/validate-token, roles: PUBLIC_ACCESS }
```
# Controllers
On the client side, create a new controller for the API. This controller need will work in a REST manner.
The route should be as follows:
```php
#[Route('/api/v1/project', name: 'api_project')] //ofc, this is an example, please THINK and change the name
```
Keep the same structure for the project tree, create an Api folder in the controller, a V1 folder and then create your controller.
Here is a full example of a controller with the create method.
On crée une organization si elle n'existe pas
```php
<?php
#[Route('/api/v2/project', name: 'api_project_')]
#[IsGranted('ROLE_API_INTERNAL')]
class ProjectApi extends AbstractController{
public function __construct(
private readonly EntityManagerInterface $entityManager)
{
}
#[Route('/create', name: 'create', methods: ['POST'])]
public function createProject(Request $request): JSONResponse
{
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$projet = new Projet();
$entity = $this->entityManager->getRepository(Entity::class)->findOneBy([ 'ssoId' => $data['orgId']]);
// Si l'entité n'existe pas, on la crée
if(!$entity){
$this->createEntity($data['orgId'], $data['orgName']);
$entity = $this->entityManager->getRepository(Entity::class)->findOneBy([ 'ssoId' => $data['orgId']]);
}
$precision= $data['timestamp'];
$validPrecisions = array_map(fn($case) => $case->value, TimestampPrecision::cases());
if (!in_array($precision, $validPrecisions, true)) {
return $this->json(['success' => false, 'message' => 'Précision d\'horodatage invalide.'], 400);
}
try {
$timestampPrecision = TimestampPrecision::from($precision);
$projet->setTimestampPrecision($timestampPrecision);
$projet->setProjet($data['name']);
$projet->setEntityId($entity);
$projet->setBdd($data['bdd']);
$projet->setIsactive($data['isActive']);
$projet->setLogo($data['logo']);
$projet->setDeletionAllowed($data['deletion']);
$projet->setSsoId($data['id']); // c'est l'id du projet dans le portail, pas la bdd local
$this->entityManager->persist($projet);
$this->entityManager->flush();
return new JsonResponse(['message' => 'Project created successfully', 'project_id' => $projet->getId()], 201);
}catch ( \Exception $e){
return new JsonResponse(['error' => 'Failed to create project: ' . $e->getMessage()], 500);
}
}
```
# Services
So, now we are getting into the thick of it. We are just COOK 🍗 ( a lot of bugs can come from here ).
We implement a new pretty service called SsoTokenHandler. This is used to get the token received from the portal request, and validate it.
It is validaded by doing a call back to the SSO and asking if the token is valid. ( we create a new token for the SSO so it handles M2M)
```php
<?php
// src/Security/SsoTokenHandler.php
namespace App\Security;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\InMemoryUser;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class SsoTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(
private HttpClientInterface $httpClient,
private string $ssoUrl
) {}
public function getUserBadgeFrom(string $accessToken): UserBadge
{
// 1. Call the SSO to validate the token
// Note: You need an endpoint on your SSO that returns 200 for M2M tokens
try {
$response = $this->httpClient->request('GET', $this->ssoUrl . '/api/validate-token', [
'headers' => ['Authorization' => 'Bearer ' . $accessToken]
]);
// If the SSO redirects, HttpClient might follow it to the login page.
// Let's see the first response code.
if ($response->getStatusCode() !== 200) {
// Log the content to see the HTML "Login" page that is causing the JSON error
// dump($response->getContent(false));
throw new BadCredentialsException();
}
} catch (\Exception $e) {
// This will show you if you are hitting a 302 Redirect
throw new BadCredentialsException('SSO returned invalid response: ' . $e->getMessage());
}
if (200 !== $response->getStatusCode()) {
throw new BadCredentialsException('Invalid SSO Token');
}
$data = $response->toArray();
// 2. Identify if it's a User or a Machine
$identifier = $data['email'] ?? 'SYSTEM_SSO_SERVER';
// 3. Return the badge with a "loader" closure
return new UserBadge($identifier, function($userIdentifier) use ($data) {
// If it's the SSO server calling, give it a specific role
if ($userIdentifier === 'SYSTEM_SSO_SERVER') {
return new InMemoryUser($userIdentifier, null, ['ROLE_API_INTERNAL']);
}
// Otherwise, let the normal user provider handle it (for standard users)
// You might need to inject your actual UserProvider here if needed
return new InMemoryUser($userIdentifier, null, ['ROLE_USER']);
});
}
}
```
# Important note 1
we need to add the portal url to the .env and declare it as a parameter in services.yaml
```dotenv
SSO_URL='http://portail.solutions-easy.moi'
```
```yaml
parameters:
sso_url: '%env(SSO_URL)%'
App\Security\SsoTokenHandler:
arguments:
$ssoUrl: '%sso_url%'
```
# Server side
On the server side, we need to create a new client, which will be himself, same as earlier, create it, and boom we are good.
The validate route is already created, so dw abt it.
```cmd
php bin/console league:oauth2-server:create-client sso_internal_service --grant-type "client_credentials"
```
now, copy the identifier, and paste it in the .env file
please note that we have 2 client for the application because one is used for m2m and the other is used for the user, so implement both, the one ending with _LOGIN is the one for the user
```dotenv
OAUTH_SSO_IDENTIFIER='sso-own-identifier'
```
and we are smart so what do we do? we add it to the services.yaml
```yaml
parameters:
oauth_sso_identifier: '%env(OAUTH_SSO_IDENTIFIER)%'
App\Service\SSO\ProjectService:
arguments:
$appUrl: '%app_url%'
$clientIdentifier: '%oauth_sso_identifier%'
```
We should be good now ( I hope ). Open the portal, try your call and check if it works, if it doesn't, check the logs and debug, you are a dev for a reason, so use your brain and debug.
If it still doesn't work, start praying, because I have no idea what to do anymore, but it works on my side, so it should work on yours, if not, well, I don't know what to say. Good luck.
Jokes aside, bugs often come from security problem, if the client returns a 401 error, it can be for multiple reasons and not necessarily because of the token but maybe because of the token validation.
Another commun bug is mismatching of the data you send, so double check. GLHF ( you won't have fun, but good luck anyway )
.
.
.
.
.
.
.
.
.
.
.
If you have a problem and that you copy pasted everything, check client controller, I've put the route as /api/v2 just cause I felt like it 🤷 ENJOY

View File

@ -178,18 +178,54 @@ class SsoAuthenticator extends OAuth2Authenticator implements AuthenticationEntr
**/
if (!$user) {
$user = new User();
$user->setEmail($sudalysSsoUser->getEmail());
$user->setName($sudalysSsoUser->getName());
$user->setSurname($sudalysSsoUser->getSurname());
$user->setSsoId($sudalysSsoUser->getId());
$user->setEmail($ssoData->getEmail());
$user->setPrenom($ssoData->getName());
$user->setNom($ssoData->getSurname());
$user->setSsoId($ssoData->getId());
$this->em->persist($user);
}else{
// On met a jour l'utilisateur
$user->setEmail($sudalysSsoUser->getEmail());
$user->setName($sudalysSsoUser->getName());
$user->setSurname($sudalysSsoUser->getSurname());
$user->setEmail($ssoData->getEmail());
$user->setPrenom($ssoData->getName());
$user->setNom($ssoData->getSurname());
$this->em->persist($user);
}
//handle UOs links
$ssoArray = $ssoData->toArray();
$uoData = $ssoArray['uos'] ?? [];
foreach ($uoData as $uo) {
$ssoOrgId = $uo['id'];
$entity = $this->em->getRepository(Entity::class)->findOneBy(['ssoId' => $ssoOrgId]);
if (!$entity) {
$entity = new Entity();
$entity->setSsoId($ssoOrgId);
$entity->setNom($uo['name']);
$this->em->persist($entity);
}
$role = $this->em->getRepository(Roles::class)->findOneBy(['name' => $uo['role']]);
// Check if the user-organization link already exists
$existingLink = $this->em->getRepository(UsersOrganizations::class)->findOneBy([
'users' => $user,
'organizations' => $entity
]);
if (!$existingLink) {
// Create a new link if it doesn't exist
$newLink = new UsersOrganizations();
$newLink->setUsers($user);
$newLink->setOrganizations($entity);
$newLink->setRole($role);
$this->em->persist($newLink);
} else {
// Update the role if the link already exists
$existingLink->setRole($role);
$existingLink->setModifiedAt(new \DateTimeImmutable());
$this->em->persist($existingLink);
}
}
$this->em->flush();
return $user;
}
@ -320,8 +356,10 @@ If there is a scope or grand error, delete the client do the following first
php bin/console league:oauth2-server:delete-client <identifier>
```
Identifier can be found in the database oauth2_client table
The recreate the client and enter the scopes and grant types after creating the client directly in the db
To recreate the client and enter the scopes and grant types after creating the client directly in the db
```text
scopes = email profile openid
grants = authorization_code
```

View File

@ -32,3 +32,138 @@ Get Access to the following with the following authorisations:
Organizations roles are specific to individual Organizations. They include:
- **Organization Admin**: Has full access to all organization features and settings. Can manage users of the organizations.
- **Organization User**: Has limited access to organization features and settings. Can view projects and applications, can manage own information
# Set up
Like for the sso, we need to create roles in the system. create the following command and the create the roles.
``` php
#[AsCommand(
name: 'app:create-role',
description: 'Creates a new role in the database'
)]
class CreateRoleCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addArgument('name', InputArgument::REQUIRED, 'The name of the role'); // role name required
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$roleName = trim($input->getArgument('name'));
$roleName = strtoupper($roleName); // Normalize to uppercase
// Ensure not empty
if ($roleName === '') {
$output->writeln('<error>The role name cannot be empty</error>');
return Command::FAILURE;
}
// Check if role already exists
$existing = $this->entityManager->getRepository(Roles::class)
->findOneBy(['name' => $roleName]);
if ($existing) {
$output->writeln("<comment>Role '{$roleName}' already exists.</comment>");
return Command::SUCCESS; // not failure, just redundant
}
// Create and persist new role
$role = new Roles();
$role->setName($roleName);
$this->entityManager->persist($role);
$this->entityManager->flush();
$output->writeln("<info>Role '{$roleName}' created successfully!</info>");
return Command::SUCCESS;
}
}
```
```php
#[AsCommand(
name: 'app:delete-role',
description: 'Deletes a role from the database'
)]
class DeleteRoleCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addArgument('name', InputArgument::REQUIRED, 'The name of the role to delete');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$roleName = trim($input->getArgument('name'));
$roleName = strtoupper($roleName); // Normalize to uppercase
if ($roleName === '') {
$output->writeln('<error>The role name cannot be empty</error>');
return Command::FAILURE;
}
// Find the role
$role = $this->entityManager->getRepository(Roles::class)
->findOneBy(['name' => $roleName]);
if (!$role) {
$output->writeln("<error>Role '{$roleName}' not found.</error>");
return Command::FAILURE;
}
// Check if role is being used (optional safety check)
$usageCount = $this->entityManager->getRepository(\App\Entity\UsersOrganizations::class)
->count(['role' => $role]);
if ($usageCount > 0) {
$output->writeln("<error>Cannot delete role '{$roleName}' - it is assigned to {$usageCount} user(s).</error>");
$output->writeln('<comment>Remove all assignments first, then try again.</comment>');
return Command::FAILURE;
}
// Confirmation prompt
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion(
"Are you sure you want to delete role '{$roleName}'? [y/N] ",
false
);
if (!$helper->ask($input, $output, $question)) {
$output->writeln('<comment>Operation cancelled.</comment>');
return Command::SUCCESS;
}
// Delete the role
$this->entityManager->remove($role);
$this->entityManager->flush();
$output->writeln("<info>Role '{$roleName}' deleted successfully!</info>");
return Command::SUCCESS;
}
}
```
``` bash
php bin/console app:create-role USER
php bin/console app:create-role ADMIN
```

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260216155056 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('DROP SEQUENCE user_organizaton_app_id_seq CASCADE');
$this->addSql('CREATE TABLE user_organization_app (id SERIAL NOT NULL, user_organization_id INT NOT NULL, application_id INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_active BOOLEAN NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_BEF66DF12014CF51 ON user_organization_app (user_organization_id)');
$this->addSql('CREATE INDEX IDX_BEF66DF13E030ACD ON user_organization_app (application_id)');
$this->addSql('COMMENT ON COLUMN user_organization_app.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT FK_BEF66DF12014CF51 FOREIGN KEY (user_organization_id) REFERENCES users_organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_app ADD CONSTRAINT FK_BEF66DF13E030ACD FOREIGN KEY (application_id) REFERENCES apps (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT fk_2c952fc72014cf51');
$this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT fk_2c952fc73e030acd');
$this->addSql('ALTER TABLE user_organizaton_app DROP CONSTRAINT fk_2c952fc7d60322ac');
$this->addSql('DROP TABLE user_organizaton_app');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('CREATE SEQUENCE user_organizaton_app_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE user_organizaton_app (id SERIAL NOT NULL, user_organization_id INT NOT NULL, role_id INT DEFAULT NULL, application_id INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_active BOOLEAN NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_2c952fc72014cf51 ON user_organizaton_app (user_organization_id)');
$this->addSql('CREATE INDEX idx_2c952fc73e030acd ON user_organizaton_app (application_id)');
$this->addSql('CREATE INDEX idx_2c952fc7d60322ac ON user_organizaton_app (role_id)');
$this->addSql('COMMENT ON COLUMN user_organizaton_app.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT fk_2c952fc72014cf51 FOREIGN KEY (user_organization_id) REFERENCES users_organizations (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT fk_2c952fc73e030acd FOREIGN KEY (application_id) REFERENCES apps (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organizaton_app ADD CONSTRAINT fk_2c952fc7d60322ac FOREIGN KEY (role_id) REFERENCES roles (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT FK_BEF66DF12014CF51');
$this->addSql('ALTER TABLE user_organization_app DROP CONSTRAINT FK_BEF66DF13E030ACD');
$this->addSql('DROP TABLE user_organization_app');
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260218111608 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE project ADD logo VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE project DROP logo');
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260218111821 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE project ADD timestamp_precision VARCHAR(10) DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD deletion_allowed BOOLEAN DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD audits_enabled BOOLEAN DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE project DROP timestamp_precision');
$this->addSql('ALTER TABLE project DROP deletion_allowed');
$this->addSql('ALTER TABLE project DROP audits_enabled');
}
}

View File

@ -51,7 +51,7 @@ class DeleteRoleCommand extends Command
}
// Check if role is being used (optional safety check)
$usageCount = $this->entityManager->getRepository(\App\Entity\UserOrganizatonApp::class)
$usageCount = $this->entityManager->getRepository(\App\Entity\UsersOrganizations::class)
->count(['role' => $role]);
if ($usageCount > 0) {

View File

@ -2,6 +2,7 @@
namespace App\Controller;
use App\Repository\UsersOrganizationsRepository;
use App\Service\AccessTokenService;
use App\Service\LoggerService;
use App\Service\UserService;
@ -20,18 +21,39 @@ class OAuth2Controller extends AbstractController
{
public function __construct(private readonly LoggerService $loggerService, private readonly UserService $userService)
public function __construct(private readonly LoggerService $loggerService, private readonly UserService $userService,
)
{
}
#[Route('/oauth2/userinfo', name: 'userinfo', methods: ['GET'])]
public function userinfo(Request $request): JsonResponse
public function userinfo(Request $request, UsersOrganizationsRepository $uoRepository): JsonResponse
{
$user = $this->getUser();
if (!$user) {
$this->loggerService->logAccessDenied($user->getId());
return new JsonResponse(['error' => 'Unauthorized'], 401);
}
$uos = $uoRepository->findBy(['users' => $user]);
$result = [];
foreach ($uos as $uo) {
$result[] = ['organization' => [
'id' => $uo->getOrganization()->getId(),
'name' => $uo->getOrganization()->getName(),
'role' => $uo->getRole()->getName()
]
];
if ($uo->getRole()->getName() === "ADMIN") {
$projets = $uo->getOrganization()->getProjects()->toArray();
$result[count($result) - 1]['organization']['projects'] = array_map(function ($projet) {
return [
'id' => $projet->getId(),
// 'name' => $projet->getName()
];
}, $projets);
}
}
$this->loggerService->logUserAction($user->getId(), $user->getId(), 'Accessed userinfo endpoint');
return new JsonResponse([
@ -39,6 +61,7 @@ class OAuth2Controller extends AbstractController
'name' => $user->getName(),
'email' => $user->getEmail(),
'surname' => $user->getSurname(),
'uos' => $result
]);
}
@ -64,7 +87,8 @@ class OAuth2Controller extends AbstractController
}
#[Route(path: '/oauth2/revoke_tokens', name: 'revoke_tokens', methods: ['POST'])]
public function revokeTokens(Security $security, Request $request, AccessTokenService $accessTokenService, LoggerInterface $logger): Response{
public function revokeTokens(Security $security, Request $request, AccessTokenService $accessTokenService, LoggerInterface $logger): Response
{
//Check if the user have valid access token
$data = json_decode($request->getContent(), true);
$userIdentifier = $data['user_identifier'];

View File

@ -6,7 +6,7 @@ use App\Entity\Actions;
use App\Entity\Apps;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use App\Form\OrganizationForm;
use App\Repository\OrganizationsRepository;

View File

@ -8,6 +8,7 @@ use App\Repository\AppsRepository;
use App\Repository\OrganizationsRepository;
use App\Repository\ProjectRepository;
use App\Service\ProjectService;
use App\Service\SSO\ProjectService as SSOProjectService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -21,11 +22,14 @@ use Symfony\Component\Routing\Attribute\Route;
final class ProjectController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly OrganizationsRepository $organizationsRepository,
private readonly ProjectRepository $projectRepository,
private readonly ProjectService $projectService,
private readonly UserService $userService, private readonly AppsRepository $appsRepository)
private readonly AppsRepository $appsRepository,
private readonly SSOProjectService $SSOProjectService,
)
{
}
@ -41,38 +45,70 @@ final class ProjectController extends AbstractController
public function new(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
if (!$data) {
return new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
} $org = $this->organizationsRepository->findOneBy(['id' => $data['organizationId']]);
if(!$org) {
return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND);
$orgId = $request->request->get('organizationId');
$name = $request->request->get('name');
$applications = $request->request->all('applications') ?? []; // Expects applications[] from JS
$org = $this->organizationsRepository->find($orgId);
if (!$org) {
return new JsonResponse(['error' => 'Organization not found'], 404);
}
$sanitizedDbName = $this->projectService->getProjectDbName($data['name'], $org->getProjectPrefix());
if($this->projectRepository->findOneBy(['bddName' => $sanitizedDbName])) {
return new JsonResponse(['error' => 'A project with the same name already exists'], Response::HTTP_CONFLICT);
// 2. Handle File Upload
$logoFile = $request->files->get('logo');
$logoPath = null;
// 3. Project Creation
$sanitizedDbName = $this->projectService->getProjectDbName($name, $org->getProjectPrefix());
if ($this->projectRepository->findOneBy(['bddName' => $sanitizedDbName])) {
return new JsonResponse(['error' => 'A project with the same name already exists'], 409);
}
if(!$this->projectService->isApplicationArrayValid($data['applications'])) {
return new JsonResponse(['error' => 'Invalid applications array'], Response::HTTP_BAD_REQUEST);
if ($logoFile) {
$logoPath = $this->projectService->handleLogoUpload($logoFile, $sanitizedDbName);
}
$project = new Project();
$project->setName($data['name']);
$project->setName($name);
$project->setBddName($sanitizedDbName);
$project->setOrganization($org);
$project->setApplications($data['applications']);
$project->setApplications($applications);
$project->setTimestampPrecision($request->request->get('timestamp'));
$project->setDeletionAllowed($request->request->getBoolean('deletion'));
if ($logoPath) {
$project->setLogo($logoPath);
}
$this->entityManager->persist($project);
$this->entityManager->flush();
return new JsonResponse(['message' => 'Project created successfully'], Response::HTTP_CREATED);
$this->entityManager->flush(); //On met le flush avant parce qu'on a besoin de l'ID du projet pour la création distante.
//Oui ducoup c'est chiant parce que le projet est créé même s'il y a une erreur API, mais OH ffs at that point. ducoup s'il y a un pb, vue que la gestion de projet et fait pas le super admin, il faudra recree le projet dans la bdd corespondant à l'appli qui fonctionne pas
// Remote creation logic
try {
foreach ($project->getApplications() as $appId) {
$app = $this->appsRepository->find($appId);
$clientUrl = 'https://' . $app->getSubDomain() . '.' .$this->getParameter('app_domain') ;
$this->SSOProjectService->createRemoteProject($clientUrl, $project);
}
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Remote creation failed: ' . $e->getMessage()], 500);
}
return new JsonResponse(['message' => 'Project created successfully'], 201);
}
#[Route(path:'/edit/{id}/ajax', name: '_edit', methods: ['POST'])]
public function edit(Request $request, int $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
if (!$data) {
return new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
} $org = $this->organizationsRepository->findOneBy(['id' => $data['organizationId']]);
$orgId = $request->request->get('organizationId');
$applications = $request->request->all('applications') ?? []; // Expects applications[] from JS
$org = $this->organizationsRepository->findOneBy(['id' => $orgId]);
if(!$org) {
return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND);
}
@ -80,9 +116,34 @@ final class ProjectController extends AbstractController
if(!$project) {
return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND);
}
$project->setApplications($data['applications']);
$logoFile = $request->files->get('logo');
$logoPath = null;
if ($logoFile) {
$logoPath = $this->projectService->handleLogoUpload($logoFile, $project->getBddName());
}
$project->setApplications($applications);
$project->setModifiedAt(new \DateTimeImmutable());
$project->setTimestampPrecision($request->request->get('timestamp'));
$project->setDeletionAllowed($request->request->getBoolean('deletion'));
if ($logoPath) {
$project->setLogo($logoPath);
}
$this->entityManager->persist($project);
// Remote editing logic
try {
foreach ($project->getApplications() as $appId) {
$app = $this->appsRepository->find($appId);
$clientUrl = 'https://' . $app->getSubDomain() . '.' .$this->getParameter('app_domain') ;
$this->SSOProjectService->editRemoteProject($clientUrl, $project);
}
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Remote creation failed: ' . $e->getMessage()], 500);
}
$this->entityManager->flush();
return new JsonResponse(['message' => 'Project updated successfully'], Response::HTTP_OK);
}
@ -144,6 +205,8 @@ final class ProjectController extends AbstractController
'id' => $project->getId(),
'name' => ucfirst($project->getName()),
'applications' => $project->getApplications(),
'timestampPrecision'=> $project->getTimestampPrecision(),
'deletionAllowed' => $project->isDeletionAllowed(),
]);
}
@ -155,6 +218,16 @@ final class ProjectController extends AbstractController
return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND);
}
$project->setIsDeleted(true);
try {
foreach ($project->getApplications() as $appId) {
$app = $this->appsRepository->find($appId);
$clientUrl = 'https://' . $app->getSubDomain() . '.' .$this->getParameter('app_domain') ;
$this->SSOProjectService->deleteRemoteProject($clientUrl, $project->getId());
}
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Remote creation failed: ' . $e->getMessage()], 500);
}
$this->entityManager->persist($project);
$this->entityManager->flush();
return new JsonResponse(['message' => 'Project deleted successfully'], Response::HTTP_OK);

View File

@ -5,7 +5,7 @@ namespace App\Controller;
use App\Entity\Apps;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use App\Form\UserForm;
use App\Repository\AppsRepository;
@ -195,88 +195,88 @@ class UserController extends AbstractController
}
#[Route('/new', name: 'new', methods: ['GET', 'POST'])]
public function new(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
try {
$actingUser =$this->getUser();
$user = new User();
$form = $this->createForm(UserForm::class, $user);
$form->handleRequest($request);
$orgId = $request->query->get('organizationId') ?? $request->request->get('organizationId');
if ($orgId) {
$org = $this->organizationRepository->find($orgId);
if (!$org) {
$this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getUserIdentifier());
$this->addFlash('danger', "L'organisation n'existe pas.");
throw $this->createNotFoundException(self::NOT_FOUND);
}
if (!$this->isGranted('ROLE_ADMIN') && !$this->userService->isAdminOfOrganization($org)) {
$this->loggerService->logAccessDenied($actingUser->getUserIdentifier());
$this->addFlash('danger', "Accès non autorisé.");
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
} else{
$this->loggerService->logAccessDenied($actingUser->getUserIdentifier());
$this->addFlash('danger', "Accès non autorisé.");
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
if ($form->isSubmitted() && $form->isValid()) {
$existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]);
// Case : User exists -> link him to given organization if not already linked, else error message
if ($existingUser && $org) {
$this->userService->addExistingUserToOrganization(
$existingUser,
$org,
);
if ($this->isGranted('ROLE_ADMIN')) {
$this->loggerService->logSuperAdmin(
$existingUser->getId(),
$actingUser->getUserIdentifier(),
"Super Admin linked user to organization",
$org->getId(),
);
}
$this->addFlash('success', 'Utilisateur ajouté avec succès à l\'organisation. ');
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
}
// Case : user doesn't already exist
$picture = $form->get('pictureUrl')->getData();
$this->userService->createNewUser($user, $actingUser, $picture);
$this->userService->linkUserToOrganization(
$user,
$org,
);
$this->addFlash('success', 'Nouvel utilisateur créé et ajouté à l\'organisation avec succès. ');
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
}
return $this->render('user/new.html.twig', [
'user' => $user,
'form' => $form->createView(),
'organizationId' => $orgId,
]);
} catch (\Exception $e) {
$this->errorLogger->critical($e->getMessage());
if ($orgId) {
$this->addFlash('danger', 'Une erreur est survenue lors de la création de l\'utilisateur pour l\'organisation .');
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
}
$this->addFlash('danger', 'Une erreur est survenue lors de la création de l\'utilisateur.');
return $this->redirectToRoute('user_index');
}
}
// #[Route('/new', name: 'new', methods: ['GET', 'POST'])]
// public function new(Request $request): Response
// {
// $this->denyAccessUnlessGranted('ROLE_USER');
// try {
// $actingUser =$this->getUser();
//
// $user = new User();
// $form = $this->createForm(UserForm::class, $user);
// $form->handleRequest($request);
//
// $orgId = $request->query->get('organizationId') ?? $request->request->get('organizationId');
// if ($orgId) {
// $org = $this->organizationRepository->find($orgId);
// if (!$org) {
// $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getUserIdentifier());
// $this->addFlash('danger', "L'organisation n'existe pas.");
// throw $this->createNotFoundException(self::NOT_FOUND);
// }
// if (!$this->isGranted('ROLE_ADMIN') && !$this->userService->isAdminOfOrganization($org)) {
// $this->loggerService->logAccessDenied($actingUser->getUserIdentifier());
// $this->addFlash('danger', "Accès non autorisé.");
// throw $this->createAccessDeniedException(self::ACCESS_DENIED);
// }
// } else{
// $this->loggerService->logAccessDenied($actingUser->getUserIdentifier());
// $this->addFlash('danger', "Accès non autorisé.");
// throw $this->createAccessDeniedException(self::ACCESS_DENIED);
// }
//
// if ($form->isSubmitted() && $form->isValid()) {
// $existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]);
//
// // Case : User exists -> link him to given organization if not already linked, else error message
// if ($existingUser && $org) {
// $this->userService->addExistingUserToOrganization(
// $existingUser,
// $org,
// );
//
// if ($this->isGranted('ROLE_ADMIN')) {
// $this->loggerService->logSuperAdmin(
// $existingUser->getId(),
// $actingUser->getUserIdentifier(),
// "Super Admin linked user to organization",
// $org->getId(),
// );
// }
// $this->addFlash('success', 'Utilisateur ajouté avec succès à l\'organisation. ');
// return $this->redirectToRoute('organization_show', ['id' => $orgId]);
// }
//
// // Case : user doesn't already exist
//
// $picture = $form->get('pictureUrl')->getData();
// $this->userService->createNewUser($user, $actingUser, $picture);
//
// $this->userService->linkUserToOrganization(
// $user,
// $org,
// );
// $this->addFlash('success', 'Nouvel utilisateur créé et ajouté à l\'organisation avec succès. ');
// return $this->redirectToRoute('organization_show', ['id' => $orgId]);
// }
//
// return $this->render('user/new.html.twig', [
// 'user' => $user,
// 'form' => $form->createView(),
// 'organizationId' => $orgId,
// ]);
//
// } catch (\Exception $e) {
// $this->errorLogger->critical($e->getMessage());
//
// if ($orgId) {
// $this->addFlash('danger', 'Une erreur est survenue lors de la création de l\'utilisateur pour l\'organisation .');
// return $this->redirectToRoute('organization_show', ['id' => $orgId]);
// }
// $this->addFlash('danger', 'Une erreur est survenue lors de la création de l\'utilisateur.');
// return $this->redirectToRoute('user_index');
// }
// }
/**
* Endpoint to activate/deactivate a user (soft delete)
@ -832,6 +832,174 @@ class UserController extends AbstractController
throw $this->createNotFoundException(self::NOT_FOUND);
}
#[Route('/new/ajax', name: 'new_ajax', methods: ['POST'])]
public function newUserAjax(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
$actingUser = $this->getUser();
try {
$data = $request->request->all();
$orgId = $data['organizationId'] ?? null;
$selectedApps = $data['applications'] ?? [];
//unset data that are not part of the User entity to avoid form errors
unset($data['organizationId'], $data['applications']);
$user = new User();
$form = $this->createForm(UserForm::class, $user, [
'csrf_protection' => false,
'allow_extra_fields' => true,
]);
$form->submit($data, false);
if (!$orgId) {
return $this->json(['error' => 'ID Organisation manquant.'], 400);
}
$org = $this->organizationRepository->find($orgId);
if (!$org) {
return $this->json(['error' => "L'organisation n'existe pas."], 404);
}
// 3. Permissions Check
if (!$this->isGranted('ROLE_ADMIN') && !$this->userService->isAdminOfOrganization($org)) {
$this->loggerService->logAccessDenied($actingUser->getUserIdentifier());
return $this->json(['error' => "Accès non autorisé."], 403);
}
if ($form->isSubmitted() && $form->isValid()) {
$email = $user->getEmail();
$existingUser = $this->userRepository->findOneBy(['email' => $email]);
// CASE A: User exists -> Add to org
if ($existingUser) {
// Check if already in org to avoid logic errors or duplicate logs
$this->userService->addExistingUserToOrganization($existingUser, $org, $selectedApps);
if ($this->isGranted('ROLE_ADMIN')) {
$this->loggerService->logSuperAdmin(
$existingUser->getId(),
$actingUser->getUserIdentifier(),
"Super Admin linked user to organization via AJAX",
$org->getId(),
);
}
return $this->json([
'success' => true,
'message' => 'Utilisateur existant ajouté à l\'organisation.'
]);
}
// CASE B: New User -> Create
// Fetch picture from $request->files since it's a multipart request
$picture = $request->files->get('pictureUrl');
$this->userService->createNewUser($user, $actingUser, $picture);
$this->userService->linkUserToOrganization($user, $org, $selectedApps);
return $this->json([
'success' => true,
'message' => 'Nouvel utilisateur créé et ajouté.'
]);
}
// If form is invalid, return the specific errors
return $this->json(['error' => 'Données de formulaire invalides.'], 400);
} catch (\Exception $e) {
$this->errorLogger->critical("AJAX User Creation Error: " . $e->getMessage());
return $this->json(['error' => 'Une erreur interne est survenue.'], 500);
}
}
#[Route('/data/{id}', name: 'user_data_json', methods: ['GET'])]
public function userData(User $user, Request $request): JsonResponse {
$orgId = $request->query->get('orgId');
$org = $this->organizationRepository->find($orgId);
if (!$org) {
$this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $this->getUser()->getUserIdentifier());
return $this->json(['error' => "L'organisation n'existe pas."], 404);
}
$uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $org]);
$apps = $this->userOrganizationAppService->getUserApplicationByOrganization($uo);
return $this->json([
'email' => $user->getEmail(),
'name' => $user->getName(),
'surname' => $user->getSurname(),
'phoneNumber' => $user->getPhoneNumber(),
'applicationIds' => array_map(fn($app) => $app->getId(), $apps),
]);
}
#[Route('/edit/{id}/ajax', name: 'edit_ajax', methods: ['POST'])]
public function editAjax(int $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
$actingUser = $this->getUser();
$user = $this->userRepository->find($id);
if (!$user) {
return $this->json(['error' => "L'utilisateur n'existe pas."], 404);
}
try {
if (!$this->userService->isAdminOfUser($user)) {
return $this->json(['error' => "Accès non autorisé."], 403);
}
$data = $request->request->all();
$orgId = $data['organizationId'] ?? null;
$selectedApps = $data['applications'] ?? [];
// 1. Clean data for the form (remove non-entity fields)
unset($data['organizationId'], $data['applications']);
$form = $this->createForm(UserForm::class, $user, [
'csrf_protection' => false,
'allow_extra_fields' => true,
]);
$form->submit($data, false);
if ($form->isSubmitted() && $form->isValid()) {
// 2. Handle User Info & Picture
$picture = $request->files->get('pictureUrl');
$this->userService->formatUserData($user, $picture);
$user->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($user);
$this->entityManager->flush();
// 3. Handle Organization-specific Application Sync
if ($orgId) {
$org = $this->organizationRepository->find($orgId);
if ($org) {
// Logic to sync applications for THIS specific organization
$uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $org]);
$this->userOrganizationAppService->syncUserApplicationsByOrganization($uo, $selectedApps);
// Create Action Log
$this->actionService->createAction("Edit user information", $actingUser, $org, $user->getUserIdentifier());
}
}
// 4. Logging
$this->loggerService->logUserAction($user->getId(), $actingUser->getUserIdentifier(), 'User information edited via AJAX');
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin($user->getId(), $actingUser->getUserIdentifier(), "Super Admin edited user via AJAX");
}
return $this->json(['success' => true, 'message' => 'Informations modifiées avec succès.']);
}
return $this->json(['error' => 'Données de formulaire invalides.'], 400);
} catch (\Exception $e) {
$this->errorLogger->critical($e->getMessage());
return $this->json(['error' => 'Une erreur est survenue lors de la modification.'], 500);
}
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Controller\api\Security;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
class SecurityController extends AbstractController{
#[Route('/api/validate-token', name: 'api_validate_token')]
public function validate(): JsonResponse
{
$user = $this->getUser();
return $this->json([
'valid' => true,
'email' => ($user instanceof \App\Entity\User) ? $user->getUserIdentifier() : null,
'scopes' => $this->container->get('security.token_storage')->getToken()->getScopes(),
]);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Controller\api\v1\user;
use App\Entity\Roles;
use App\Entity\UsersOrganizations;
use App\Repository\RolesRepository;
use App\Repository\UserRepository;
use App\Repository\UsersOrganizationsRepository;
use App\Service\LoggerService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/api/v1/users', name: 'api_v1_user_')]
class UserController extends AbstractController{
public function __construct(private readonly UsersOrganizationsRepository $uoRepository, private readonly LoggerService $loggerService, private readonly EntityManagerInterface $entityManager,)
{
}
/*Function that get all the users that a user is admin of*/
#[Route(path: '/admin/{id}', name: 'get_user_users', methods: ['GET'])]
public function getUserUsers($id, UserRepository $userRepository): JsonResponse
{
$result = [];
$user = $userRepository->find($id);
if (!$user) {
return $this->json(['error' => 'User not found'], 404);
}
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
$uos = $this->uoRepository->findBy(['user' => $user, 'role' => $roleAdmin]);
foreach ($uos as $uo) {
$result[] = [
'id' => $uo->getUsers()->getId(),
'name' => $uo->getUsers()->getName(),
'email' => $uo->getUsers()->getEmail(),
];
}
return $this->json($result);
}
}

View File

@ -32,4 +32,9 @@ final class AccessToken implements AccessTokenEntityInterface
->getToken($this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey());
}
public function setUserIdentifier(?string $userIdentifier): void
{
$this->userIdentifier = $userIdentifier;
}
}

View File

@ -38,9 +38,9 @@ class Apps
private ?string $descriptionSmall = null;
/**
* @var Collection<int, UserOrganizatonApp>
* @var Collection<int, UserOrganizationApp>
*/
#[ORM\OneToMany(targetEntity: UserOrganizatonApp::class, mappedBy: 'application')]
#[ORM\OneToMany(targetEntity: UserOrganizationApp::class, mappedBy: 'application')]
private Collection $userOrganizatonApps;
/**
@ -152,14 +152,14 @@ class Apps
}
/**
* @return Collection<int, UserOrganizatonApp>
* @return Collection<int, UserOrganizationApp>
*/
public function getUserOrganizatonApps(): Collection
{
return $this->userOrganizatonApps;
}
public function addUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static
public function addUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static
{
if (!$this->userOrganizatonApps->contains($userOrganizatonApp)) {
$this->userOrganizatonApps->add($userOrganizatonApp);
@ -169,7 +169,7 @@ class Apps
return $this;
}
public function removeUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static
public function removeUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static
{
if ($this->userOrganizatonApps->removeElement($userOrganizatonApp)) {
if ($userOrganizatonApp->getApplication() === $this) {

View File

@ -56,9 +56,9 @@ class Organizations
private Collection $actions;
/**
* @var Collection<int, UserOrganizatonApp>
* @var Collection<int, UserOrganizationApp>
*/
#[ORM\OneToMany(targetEntity: UserOrganizatonApp::class, mappedBy: 'organization')]
#[ORM\OneToMany(targetEntity: UserOrganizationApp::class, mappedBy: 'organization')]
private Collection $userOrganizatonApps;
#[ORM\Column(length: 4, nullable: true)]
@ -238,14 +238,14 @@ class Organizations
}
/**
* @return Collection<int, UserOrganizatonApp>
* @return Collection<int, UserOrganizationApp>
*/
public function getUserOrganizatonApps(): Collection
{
return $this->userOrganizatonApps;
}
public function addUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static
public function addUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static
{
if (!$this->userOrganizatonApps->contains($userOrganizatonApp)) {
$this->userOrganizatonApps->add($userOrganizatonApp);
@ -255,7 +255,7 @@ class Organizations
return $this;
}
public function removeUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static
public function removeUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static
{
if ($this->userOrganizatonApps->removeElement($userOrganizatonApp)) {
// set the owning side to null (unless already changed)

View File

@ -35,9 +35,21 @@ class Project
#[ORM\Column]
private ?bool $isDeleted = null;
#[ORM\Column(length: 255, nullable: true)]
#[ORM\Column(length: 255)]
private ?string $bddName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $logo = null;
#[ORM\Column(length: 10)]
private ?string $timestampPrecision = null;
#[ORM\Column()]
private ?bool $deletionAllowed = null;
#[ORM\Column(nullable: true)]
private ?bool $auditsEnabled = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
@ -146,4 +158,52 @@ class Project
return $this;
}
public function getLogo(): ?string
{
return $this->logo;
}
public function setLogo(?string $logo): static
{
$this->logo = $logo;
return $this;
}
public function getTimestampPrecision(): ?string
{
return $this->timestampPrecision;
}
public function setTimestampPrecision(?string $timestampPrecision): static
{
$this->timestampPrecision = $timestampPrecision;
return $this;
}
public function isDeletionAllowed(): ?bool
{
return $this->deletionAllowed;
}
public function setDeletionAllowed(?bool $deletionAllowed): static
{
$this->deletionAllowed = $deletionAllowed;
return $this;
}
public function isAuditsEnabled(): ?bool
{
return $this->auditsEnabled;
}
public function setAuditsEnabled(?bool $auditsEnabled): static
{
$this->auditsEnabled = $auditsEnabled;
return $this;
}
}

View File

@ -2,11 +2,11 @@
namespace App\Entity;
use App\Repository\UserOrganizatonAppRepository;
use App\Repository\UserOrganizationAppRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserOrganizatonAppRepository::class)]
class UserOrganizatonApp
#[ORM\Entity(repositoryClass: UserOrganizationAppRepository::class)]
class UserOrganizationApp
{
#[ORM\Id]
#[ORM\GeneratedValue]
@ -16,17 +16,15 @@ class UserOrganizatonApp
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne]
private ?Roles $role = null;
#[ORM\Column]
private ?bool $isActive;
#[ORM\ManyToOne(inversedBy: 'userOrganizatonApps')]
#[ORM\ManyToOne(inversedBy: 'userOrganizationApps')]
#[ORM\JoinColumn(nullable: false)]
private ?UsersOrganizations $userOrganization = null;
#[ORM\ManyToOne(inversedBy: 'userOrganizatonApps')]
#[ORM\ManyToOne(inversedBy: 'userOrganizationApps')]
#[ORM\JoinColumn(nullable: false)]
private ?Apps $application = null;
@ -53,18 +51,6 @@ class UserOrganizatonApp
return $this;
}
public function getRole(): ?Roles
{
return $this->role;
}
public function setRole(?Roles $role): static
{
$this->role = $role;
return $this;
}
public function isActive(): ?bool
{
return $this->isActive;

View File

@ -30,9 +30,9 @@ class UsersOrganizations
private ?\DateTimeImmutable $createdAt = null;
/**
* @var Collection<int, UserOrganizatonApp>
* @var Collection<int, UserOrganizationApp>
*/
#[ORM\OneToMany(targetEntity: UserOrganizatonApp::class, mappedBy: 'userOrganization')]
#[ORM\OneToMany(targetEntity: UserOrganizationApp::class, mappedBy: 'userOrganization')]
private Collection $userOrganizatonApps;
#[ORM\Column(length: 255, nullable: true)]
@ -98,14 +98,14 @@ class UsersOrganizations
}
/**
* @return Collection<int, UserOrganizatonApp>
* @return Collection<int, UserOrganizationApp>
*/
public function getUserOrganizatonApps(): Collection
{
return $this->userOrganizatonApps;
}
public function addUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static
public function addUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static
{
if (!$this->userOrganizatonApps->contains($userOrganizatonApp)) {
$this->userOrganizatonApps->add($userOrganizatonApp);
@ -115,7 +115,7 @@ class UsersOrganizations
return $this;
}
public function removeUserOrganizatonApp(UserOrganizatonApp $userOrganizatonApp): static
public function removeUserOrganizatonApp(UserOrganizationApp $userOrganizatonApp): static
{
if ($this->userOrganizatonApps->removeElement($userOrganizatonApp)) {
// set the owning side to null (unless already changed)

View File

@ -14,7 +14,8 @@ class LoginSubscriber implements EventSubscriberInterface
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(EntityManagerInterface $entityManager,
private string $clientIdentifier)
{
$this->entityManager = $entityManager;
}
@ -28,19 +29,37 @@ class LoginSubscriber implements EventSubscriberInterface
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$user = $event->getUser();
if($user) {
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $user->getUserIdentifier()]);
$passportUser = $event->getUser();
// 1. Check if we have a user at all
if (!$passportUser) {
return;
}
// 2. IMPORTANT: Check if this is a real User entity from your DB.
// If it's a Machine/Client login, it will be an instance of
// League\Bundle\OAuth2ServerBundle\Security\User\ClientCredentialsUser
if (!$passportUser instanceof \App\Entity\User) {
// It's a machine (M2M), so we don't track "last connection" or create manual tokens
return;
}
// Now we know it's a real human user
$user = $this->entityManager->getRepository(User::class)->findOneBy([
'email' => $passportUser->getUserIdentifier()
]);
if ($user) {
$user->setLastConnection(new \DateTime('now', new \DateTimeZone('Europe/Paris')));
$easySolution = $this->entityManager->getRepository(Client::class)->findOneBy(['name' => 'EasySolution']);
if($easySolution) {
$easySolution = $this->entityManager->getRepository(Client::class)->findOneBy(['identifier' => $this->clientIdentifier]);
if ($easySolution) {
$accessToken = new AccessToken(
identifier: bin2hex(random_bytes(40)), // Generate unique identifier
identifier: bin2hex(random_bytes(40)),
expiry: new \DateTimeImmutable('+1 hour', new \DateTimeZone('Europe/Paris')),
client: $easySolution,
userIdentifier: $user->getUserIdentifier(),
scopes: ['email profile openid apps:easySolutions'] // Empty array if no specific scopes needed
scopes: ['email', 'profile', 'openid', 'apps:easySolutions']
);
$this->entityManager->persist($user);
$this->entityManager->persist($accessToken);

View File

@ -25,8 +25,7 @@ final class AccessTokenRepository implements AccessTokenRepositoryInterface
/** @var int|string|null $userIdentifier */
$accessToken = new AccessTokenEntity();
$accessToken->setClient($clientEntity);
$accessToken->setUserIdentifier($userIdentifier);
$accessToken->setUserIdentifier($userIdentifier ?? $clientEntity->getIdentifier());
foreach ($scopes as $scope) {
$accessToken->addScope($scope);
}

View File

@ -3,18 +3,18 @@
namespace App\Repository;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserOrganizatonApp>
* @extends ServiceEntityRepository<UserOrganizationApp>
*/
class UserOrganizatonAppRepository extends ServiceEntityRepository
class UserOrganizationAppRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserOrganizatonApp::class);
parent::__construct($registry, UserOrganizationApp::class);
}
// /**

View File

@ -5,7 +5,7 @@ namespace App\Service;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use App\Repository\UsersOrganizationsRepository;
use App\Service\LoggerService;
@ -145,7 +145,7 @@ class OrganizationsService
$adminUOs = $this->uoRepository->findBy(['organization' => $data['organization'], 'isActive' => true]);
foreach ($adminUOs as $adminUO) {
$uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)
$uoa = $this->entityManager->getRepository(UserOrganizationApp::class)
->findOneBy([
'userOrganization' => $adminUO,
'role' => $roleAdmin,

View File

@ -3,12 +3,16 @@
namespace App\Service;
use App\Repository\AppsRepository;
use App\Service\LoggerService;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\String\Slugger\AsciiSlugger;
class ProjectService{
public function __construct(private readonly AppsRepository $appsRepository)
public function __construct(private readonly AppsRepository $appsRepository, private readonly Security $security, private readonly LoggerService $loggerService)
{
}
@ -40,4 +44,43 @@ class ProjectService{
}
return true;
}
public function handleLogoUpload($logoFile, $projectBddName): ?string
{
// 1. Define the destination directory (adjust path as needed, e.g., 'public/uploads/profile_pictures')
$destinationDir = 'uploads/project_logos';
// 2. Create the directory if it doesn't exist
if (!file_exists($destinationDir)) {
// 0755 is the standard permission (Owner: read/write/exec, Others: read/exec)
if (!mkdir($destinationDir, 0755, true) && !is_dir($destinationDir)) {
throw new \RuntimeException(sprintf('Directory "%s" was not created', $destinationDir));
}
}
$extension = $logoFile->guessExtension();
// Sanitize the filename to remove special characters/spaces to prevent filesystem errors
$customFilename = $projectBddName . '.' . $extension;
try {
// 4. Move the file to the destination directory
$logoFile->move($destinationDir, $customFilename);
// 5. Update the user entity with the relative path
// Ensure you store the path relative to your public folder usually
return $destinationDir . '/' . $customFilename;
} catch (\Exception $e) {
// 6. Log the critical error as requested
$this->loggerService->logError('File upload failed',[
'target_user_id' => $this->security->getUser()->getId(),
'message' => $e->getMessage(),
'file_name' => $customFilename,
]);
// Optional: Re-throw the exception if you want the controller/user to know the upload failed
throw new FileException('File upload failed.');
}
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Service\SSO;
use App\Entity\Project;
use Doctrine\ORM\EntityManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\Client;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class ProjectService
{
public function __construct(private readonly HttpClientInterface $httpClient,
private string $appUrl,
private string $clientIdentifier, private readonly EntityManagerInterface $entityManager)
{
}
// Inside your SSO Server Service
public function createRemoteProject(string $clientAppUrl, Project $project): void
{
// 1. Get a token for "ourselves" -> on en a besoin parce que c'est du M2M.
$tokenResponse = $this->getTokenResponse();
$accessToken = $tokenResponse->toArray()['access_token'];
// data must match easy check database
$projectJson = $this->getProjectToJson($project);
// 2. Call the Client Application's Webhook/API
$this->httpClient->request('POST', $clientAppUrl . '/api/v1/project/create', [
'headers' => ['Authorization' => 'Bearer ' . $accessToken],
'json' => $projectJson
]);
}
public function editRemoteProject(string $clientAppUrl, Project $project): void
{
$tokenResponse = $this->getTokenResponse();
$accessToken = $tokenResponse->toArray()['access_token'];
// data must match easy check database
$projectJson = $this->getProjectToJson($project);
// 2. Call the Client Application's Webhook/API
$this->httpClient->request('PUT', $clientAppUrl . '/api/v1/project/edit/'. $project->getId(), [
'headers' => ['Authorization' => 'Bearer ' . $accessToken],
'json' => $projectJson
]);
}
public function deleteRemoteProject(string $clientAppUrl, int $projectId): void
{
$tokenResponse = $this->getTokenResponse();
$accessToken = $tokenResponse->toArray()['access_token'];
// 2. Call the Client Application's Webhook/API
$this->httpClient->request('DELETE', $clientAppUrl . '/api/v1/project/delete/'. $projectId, [
'headers' => ['Authorization' => 'Bearer ' . $accessToken],
]);
}
public function getTokenResponse(): ResponseInterface{
$portalClient = $this->entityManager->getRepository(Client::class)->findOneBy(['identifier' => $this->clientIdentifier]);
return $this->httpClient->request('POST', $this->appUrl . 'token', [
'auth_basic' => [$portalClient->getIdentifier(),$portalClient->getSecret()], // ID and Secret go here
'body' => [
'grant_type' => 'client_credentials',
],
]);
}
public function getProjectToJson(Project $project): array {
return [
'id' => $project->getId(),
'name' => $project->getName(),
'orgId' => $project->getOrganization()->getId(),
'orgName' => $project->getOrganization()->getName(),
'bdd' => $project->getBddName(),
'isActive' => $project->isActive(),
'logo' => $project->getLogo(),
'timestamp'=> $project->getTimestampPrecision(),
'deletion' => $project->isDeletionAllowed()
];
}
}

View File

@ -5,8 +5,10 @@ namespace App\Service;
use App\Entity\Apps;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use App\Repository\UserOrganizationAppRepository;
use App\Repository\UsersOrganizationsRepository;
use App\Service\ActionService;
use App\Service\LoggerService;
use App\Service\UserService;
@ -16,7 +18,15 @@ use Symfony\Bundle\SecurityBundle\Security;
class UserOrganizationAppService
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ActionService $actionService, private readonly Security $security, private readonly UserService $userService, private readonly LoggerInterface $logger, private readonly LoggerService $loggerService)
public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly ActionService $actionService,
private readonly Security $security,
private readonly UserService $userService,
private readonly LoggerInterface $logger,
private readonly LoggerService $loggerService,
private readonly UsersOrganizationsRepository $usersOrganizationsRepository,
private readonly UserOrganizationAppRepository $uoaRepository)
{
}
@ -76,9 +86,9 @@ class UserOrganizationAppService
public function deactivateAllUserOrganizationsAppLinks(UsersOrganizations $userOrganization, Apps $app = null): void
{
if($app) {
$uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy(['userOrganization' => $userOrganization, 'application' => $app, 'isActive' => true]);
$uoas = $this->uoaRepository->findBy(['userOrganization' => $userOrganization, 'application' => $app, 'isActive' => true]);
} else {
$uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy(['userOrganization' => $userOrganization, 'isActive' => true]);
$uoas = $this->uoaRepository->findBy(['userOrganization' => $userOrganization, 'isActive' => true]);
}
foreach ($uoas as $uoa) {
try{
@ -98,156 +108,6 @@ class UserOrganizationAppService
}
}
/**
* Synchronizes user roles for a specific application within an organization.
*
* This method handles the complete lifecycle of user-application role assignments:
* - Activates/deactivates existing role links based on selection
* - Creates new role assignments for newly selected roles
* - Updates the user's global Symfony security roles when ADMIN/SUPER_ADMIN roles are assigned
*
* @param UsersOrganizations $uo The user-organization relationship
* @param Apps $application The target application
* @param array $selectedRoleIds Array of role IDs that should be active for this user-app combination
* @param User $actingUser The user performing this action (for audit logging)
*
* @return void
*
* @throws \Exception If role entities cannot be found or persisted
*/
public function syncRolesForUserOrganizationApp(
UsersOrganizations $uo,
Apps $application,
array $selectedRoleIds,
User $actingUser
): void {
// Fetch existing UserOrganizationApp links for this user and application
$uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy([
'userOrganization' => $uo,
'application' => $application,
]);
$currentRoleIds = [];
// Process existing role links - activate or deactivate based on selection
foreach ($uoas as $uoa) {
$roleId = $uoa->getRole()->getId();
$currentRoleIds[] = $roleId;
$roleName = $uoa->getRole()->getName();
if (in_array((string) $roleId, $selectedRoleIds, true)) {
// Role is selected - ensure it's active
if (!$uoa->isActive()) {
$uoa->setIsActive(true);
$this->entityManager->persist($uoa);
$this->loggerService->logOrganizationInformation(
$uo->getOrganization()->getId(),
$actingUser->getId(),
"Re-activated role '$roleName' for user '{$uo->getUsers()->getId()}' in application '{$application->getName()} with UOA ID {$uoa->getId()}'"
);
$this->actionService->createAction(
"Re-activate user role for application",
$actingUser,
$uo->getOrganization(),
"App: {$application->getName()}, Role: $roleName for user {$uo->getUsers()->getUserIdentifier()}"
);
// Sync Admins roles to user's global Symfony security roles
if (in_array($roleName, ['ADMIN', 'SUPER ADMIN'], true)) {
$this->userService->syncUserRoles($uo->getUsers(), $roleName, true);
}
// Ensure ADMIN role is assigned if SUPER ADMIN is activated
if ($roleName === 'SUPER ADMIN') {
$this->ensureAdminRoleForSuperAdmin($uoa);
}
}
} else {
// Role is not selected - ensure it's inactive
if ($uoa->isActive()) {
$uoa->setIsActive(false);
$this->entityManager->persist($uoa);
$this->loggerService->logOrganizationInformation(
$uo->getOrganization()->getId(),
$actingUser->getId(),
"Deactivated role '$roleName' for user '{$uo->getUsers()->getId()}' in application '{$application->getName()}' with UOA ID {$uoa->getId()}'"
);
$this->actionService->createAction(
"Deactivate user role for application",
$actingUser,
$uo->getOrganization(),
"App: {$application->getName()}, Role: $roleName for user {$uo->getUsers()->getUserIdentifier()}"
);
// Sync Admins roles to user's global Symfony security roles
if (in_array($roleName, ['ADMIN', 'SUPER ADMIN'], true)) {
$this->userService->syncUserRoles($uo->getUsers(), $roleName, false);
}
}
}
}
// Create new role assignments for roles that don't exist yet
foreach ($selectedRoleIds as $roleId) {
if (!in_array($roleId, $currentRoleIds)) {
$role = $this->entityManager->getRepository(Roles::class)->find($roleId);
if ($role) {
// Create new user-organization-application role link
$newUoa = new UserOrganizatonApp();
$newUoa->setUserOrganization($uo);
$newUoa->setApplication($application);
$newUoa->setRole($role);
$newUoa->setIsActive(true);
// Sync Admins roles to user's global Symfony security roles
if (in_array($role->getName(), ['ADMIN', 'SUPER ADMIN'], true)) {
$this->userService->syncUserRoles($uo->getUsers(), $role->getName(), true);
}
// Ensure ADMIN role is assigned if SUPER ADMIN is activated
if ($role->getName() === 'SUPER ADMIN') {
$this->ensureAdminRoleForSuperAdmin($newUoa);
}
$this->entityManager->persist($newUoa);
$this->loggerService->logOrganizationInformation(
$uo->getOrganization()->getId(),
$actingUser->getId(),
"Created new role '{$role->getName()}' for user '{$uo->getUsers()->getId()}' in application '{$application->getName()}' with UOA ID {$newUoa->getId()}'"
);
$this->actionService->createAction("New user role for application",
$actingUser,
$uo->getOrganization(),
"App: {$application->getName()}, Role: {$role->getName()} for user {$uo->getUsers()->getUserIdentifier()}");
}
}
}
$this->entityManager->flush();
}
/**
* Attribute the role Admin to the user if the user has the role Super Admin
*
* @param UserOrganizatonApp $uoa
*
* @return void
*/
public function ensureAdminRoleForSuperAdmin(UserOrganizatonApp $uoa): void
{
$uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy([
'userOrganization' => $uoa->getUserOrganization(),
'application' => $uoa->getApplication(),
'role' => $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN'])
]);
if(!$uoaAdmin) {
$uoaAdmin = new UserOrganizatonApp();
$uoaAdmin->setUserOrganization($uoa->getUserOrganization());
$uoaAdmin->setApplication($uoa->getApplication());
$uoaAdmin->setRole($this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']));
$uoaAdmin->setIsActive(true);
$this->entityManager->persist($uoaAdmin);
}
// If the ADMIN role link exists but is inactive, activate it
if ($uoaAdmin && !$uoaAdmin->isActive()) {
$uoaAdmin->setIsActive(true);
}
}
/**
* Get users applications links for a given user
@ -257,10 +117,10 @@ class UserOrganizationAppService
*/
public function getUserApplications(User $user): array
{
$uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]);
$uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user, 'isActive' => true]);
$apps = [];
foreach ($uos as $uo) {
$uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy(['userOrganization' => $uo, 'isActive' => true]);
$uoas = $this->uoaRepository->findBy(['userOrganization' => $uo, 'isActive' => true]);
foreach ($uoas as $uoa) {
$app = $uoa->getApplication();
if (!in_array($app, $apps, true)) {
@ -270,4 +130,82 @@ class UserOrganizationAppService
}
return $apps;
}
public function getUserApplicationByOrganization(UsersOrganizations $uo): array
{
$uoas = $this->uoaRepository->findBy(['userOrganization' => $uo, 'isActive' => true]);
$apps = [];
foreach ($uoas as $uoa) {
$app = $uoa->getApplication();
if (!in_array($app, $apps, true)) {
$apps[] = $app;
}
}
return $apps;
}
public function syncUserApplicationsByOrganization(UsersOrganizations $uo, array $selectedApps): void
{
// 1. Get all currently active applications for this specific User-Organization link
$currentUolas = $this->uoaRepository->findBy([
'userOrganization' => $uo,
'isActive' => true
]);
// Track which app IDs are currently active in the DB
$currentAppIds = array_map(fn($uoa) => $uoa->getApplication()->getId(), $currentUolas);
// 2. REMOVAL: Deactivate apps that are in the DB but NOT in the new selection
foreach ($currentUolas as $uoa) {
$appId = $uoa->getApplication()->getId();
if (!in_array($appId, $selectedApps)) {
$uoa->setIsActive(false);
$this->actionService->createAction(
"Deactivate UOA link",
$uo->getUsers(),
$uo->getOrganization(),
"App: " . $uoa->getApplication()->getName()
);
}
}
// 3. ADDITION / REACTIVATION: Handle the selected apps
foreach ($selectedApps as $appId) {
$app = $this->entityManager->getRepository(Apps::class)->find($appId);
if (!$app) continue;
// Check if a record (active or inactive) already exists
$existingUOA = $this->uoaRepository->findOneBy([
'userOrganization' => $uo,
'application' => $app
]);
if (!$existingUOA) {
// Create new if it never existed
$newUOA = new UserOrganizationApp();
$newUOA->setUserOrganization($uo);
$newUOA->setApplication($app);
$newUOA->setIsActive(true);
$this->entityManager->persist($newUOA);
$this->actionService->createAction(
"Activate UOA link",
$uo->getUsers(),
$uo->getOrganization(),
"App: " . $app->getName()
);
} elseif (!$existingUOA->isActive()) {
// Reactivate if it was previously disabled
$existingUOA->setIsActive(true);
$this->actionService->createAction(
"Reactivate UOA link",
$uo->getUsers(),
$uo->getOrganization(),
"App: " . $app->getName()
);
}
}
$this->entityManager->flush();
}
}

View File

@ -3,10 +3,11 @@
namespace App\Service;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use App\Repository\RolesRepository;
use DateTimeImmutable;
@ -361,7 +362,7 @@ class UserService
->findBy(['users' => $user, 'isActive' => true]);
$hasRole = false;
foreach ($uos as $uo) {
$uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)
$uoa = $this->entityManager->getRepository(UserOrganizationApp::class)
->findBy([
'userOrganization' => $uo,
'isActive' => true,
@ -533,17 +534,17 @@ class UserService
*
* @param User $user
* @param Organizations $organization
* @return void
* @param array $selectedApps
* @return int
* @throws Exception
*/
public function handleExistingUser(User $user, Organizations $organization): int
public function reactivateUser(User $user, Organizations $organization, array $selectedApps): int
{
if (!$user->isActive()) {
$user->setIsActive(true);
$this->entityManager->persist($user);
}
$uo = $this->linkUserToOrganization($user, $organization);
return $uo->getId();
return $this->linkUserToOrganization($user, $organization, $selectedApps)->getId();
}
/**
@ -554,13 +555,15 @@ class UserService
* Handle picture if provided
*
* @param User $user
* @param $picture
* @param bool $setPassword
* @return void
*/
public function formatUserData(User $user, $picture, bool $setPassword = false): void
{
// capitalize name and surname
$user->setName(ucfirst(strtolower($user->getName())));
$user->setSurname(ucfirst(strtolower($user->getSurname())));
$user->setSurname(strtoupper($user->getSurname()));
// trim strings
$user->setName(trim($user->getName()));
@ -589,11 +592,12 @@ class UserService
public function addExistingUserToOrganization(
User $existingUser,
Organizations $org,
array $selectedApps
): int
{
try {
$uoId = $this->handleExistingUser($existingUser, $org);
$actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier());
try {
$uoId = $this->reactivateUser($existingUser, $org, $selectedApps);
$this->loggerService->logExistingUserAddedToOrg(
$existingUser->getId(),
$org->getId(),
@ -647,6 +651,7 @@ class UserService
public function linkUserToOrganization(
User $user,
Organizations $org,
array $selectedApps
): UsersOrganizations
{
$actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier());
@ -660,6 +665,7 @@ class UserService
$uo->setRole($roleUser);
$uo->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($uo);
$this->linkUOToApps($uo, $selectedApps);
$this->entityManager->flush();
$this->loggerService->logUserOrganizationLinkCreated(
@ -731,4 +737,18 @@ class UserService
}
}
private function linkUOToApps(UsersOrganizations $uo, array $selectedApps):void
{
foreach ($selectedApps as $appId){
$uoa = new UserOrganizationApp();
$uoa->setUserOrganization($uo);
$app = $this->entityManager->getRepository(Apps::class)->find($appId);
if ($app) {
$uoa->setApplication($app);
$this->entityManager->persist($uoa);
}
}
$this->entityManager->flush();
}
}

View File

@ -1,7 +1,7 @@
{% extends 'base.html.twig' %}
{% block body %}
{% set isSA = is_granted('ROLE_SUPER_ADMIN')%}
{% set isSA = is_granted('ROLE_SUPER_ADMIN') %}
<div class="w-100 h-100 p-5 m-auto">
{% for type, messages in app.flashes %}
{% for message in messages %}
@ -52,20 +52,75 @@
{# User tables #}
<div class="col-9">
<div class="row mb-3 d-flex gap-2 ">
<div class="col mb-3 card no-header-bg">
<div class="col mb-3 card no-header-bg"
data-controller="user"
data-user-org-id-value="{{ organization.id }}"
data-user-new-value="true"
data-user-list-small-value="true">
<div class="card-header d-flex justify-content-between align-items-center">
<h2>
Nouveaux utilisateurs
</h2>
<a href="{{ path('user_new', {'organizationId': organization.id}) }}"
class="btn btn-primary">Ajouter un utilisateur</a>
<h2>Nouveaux utilisateurs</h2>
{# Button to trigger modal #}
<button type="button" class="btn btn-primary" data-action="click->user#openNewUserModal">
Ajouter un utilisateur
</button>
</div>
<div class="card-body">
<div id="tabulator-userListSmall" data-controller="user"
data-user-aws-value="{{ aws_url }}"
data-user-new-value="true"
data-user-list-small-value="true"
data-user-org-id-value="{{ organization.id }}">
<div id="tabulator-userListSmall"></div>
</div>
{# New User Modal #}
<div class="modal fade" id="newUserModal" tabindex="-1" aria-hidden="true"
data-user-target="modal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Créer un nouvel utilisateur</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form data-action="submit->user#submitNewUser">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Email*</label>
<input type="email" name="email" class="form-control" required>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">Prénom*</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="col-6 mb-3">
<label class="form-label">Nom*</label>
<input type="text" name="surname" class="form-control" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Numéro de téléphone</label>
<input type="number" name="phoneNumber" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Photo de profil</label>
<input type="file" name="pictureUrl" class="form-control"
accept="image/*">
</div>
<hr>
<label class="form-label"><b>Applications à associer**</b></label>
<div class="row" data-user-target="appList">
{# Applications will be injected here #}
<div class="text-center p-3 text-muted">Chargement des applications...
</div>
</div>
<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">Créer l'utilisateur</button>
</div>
</form>
</div>
</div>
</div>
</div>
@ -87,18 +142,21 @@
</div>
{# Modal for Adding Admin #}
<div class="modal fade" id="addAdminModal" tabindex="-1" aria-hidden="true" data-user-target="modal">
<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>
<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>
<select name="userId" class="form-select" data-user-target="userSelect"
required>
<option value="">Chargement...</option>
</select>
</div>
@ -107,7 +165,9 @@
<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="button" class="btn btn-secondary" data-bs-dismiss="modal">
Annuler
</button>
<button type="submit" class="btn btn-primary">Ajouter</button>
</div>
</form>
@ -133,7 +193,6 @@
</div>
{# APPLICATION ROW #}
{# TODO:remove app acces and replace wioth project overview#}
<div class="row mb-3 card no-header-bg"
data-controller="project"
data-project-list-project-value="true"
@ -153,7 +212,8 @@
</div>
</div>
<div class="modal fade" id="createProjectModal" tabindex="-1" aria-hidden="true" data-project-target="modal">
<div class="modal fade" id="createProjectModal" tabindex="-1" aria-hidden="true"
data-project-target="modal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
@ -163,20 +223,63 @@
<form data-action="submit->project#submitForm">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Nom du projet</label>
<label class="form-label">
<i class="color-primary">{{ ux_icon('bi:input-cursor-text', {height: '15px', width: '15px'}) }}</i>
Nom du projet</label>
<input type="text" name="name"
data-project-target="nameInput"
class="form-control" required>
</div>
<div class="row">
<div class="mb-3 col-6">
<label class="form-label">
<i class="color-primary">
{{ ux_icon('bi:calendar', {height: '15px', width: '15px'}) }}
</i>Horodatage</label>
<select name="timestamp" class="form-select"
data-project-target="timestampSelect" required>
<option value="day">Jour uniquement (YYYY-MM-DD)</option>
<option value="full">Horodatage complet (YYYY-MM-DD HH:MM:SS)
</option>
</select>
</div>
<div class="mb-3 col-6">
<label class="form-label">
<i class="color-primary">
{{ ux_icon('bi:trash3', {height: '15px', width: '15px'}) }}
</i>
Autorisation de suppression</label>
<select name="deletion" class="form-select"
data-project-target="deletionSelect" required>
<option value="true">Suppression autorisée</option>
<option value="false">Suppression interdite</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">
<i class="color-primary">
{{ ux_icon('material-symbols:picture-in-picture-outline-rounded', {height: '15px', width: '15px'}) }}
</i>
Logo de projet
</label>
<input type="file" name="logo" class="form-control"
accept="image/*">
</div>
</div>
<label class="form-label">Applications</label>
<label class="form-label">
<i class="color-primary">{{ ux_icon('bi:grid-3x3-gap', {height: '15px', width: '15px'}) }}</i>
Applications</label>
<div class="row" data-project-target="appList">
{# Checkboxes will be injected here #}
<div class="text-center p-3">Chargement des applications...</div>
</div>
<input name="organizationId" type="hidden" value="{{ organization.id }}">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Annuler
</button>
<button type="submit" class="btn btn-primary">Enregistrer</button>
</div>
</form>
@ -189,7 +292,7 @@
<div class="col-3 m-auto">
<div class="card "
data-controller="organization"
data-organization-activities-value = "true"
data-organization-activities-value="true"
data-organization-id-value="{{ organization.id }}">
<div class="card-header d-flex justify-content-between align-items-center border-0">

View File

@ -33,112 +33,19 @@
{% include 'user/userInformation.html.twig' %}
<div class="card border-0 no-header-bg ">
<div class="card-header">
<div class="card-title">
<h1>Information d'organisation</h1>
</div>
</div>
<div class="card-body ms-4">
{# TODO: dynamic number of project#}
<p><b>Projet : </b>69 projets vous sont attribués</p>
</div>
{# <div class="card-body">#}
{# <div class="row g-2">#}
{# {% for app in apps %}#}
{# <div class="col-12 col-md-6">#}
{# <div class="card h-100">#}
{# <div class="card-header d-flex gap-2">#}
{# {% if app.logoMiniUrl %}#}
{# <img src="{{ asset(appli.entity.logoMiniUrl) }}" alt="Logo {{ app.name }}"#}
{# class="rounded-circle" style="width:50px; height:50px;">#}
{# {% endif %}#}
{# <div class="card border-0 no-header-bg ">#}
{# <div class="card-header">#}
{# <div class="card-title">#}
{# <h1>{{ app.name|title }}</h1>#}
{# <h1>Information d'organisation</h1>#}
{# </div>#}
{# </div>#}
{# <div class="card-body ms-4">#}
{# TODO: dynamic number of project#}
{# <p><b>Projet : </b>69 projets vous sont attribués</p>#}
{# </div>#}
{# <div class="card-body">#}
{# <div class="row">#}
{# <p>#}
{# <b>Description :</b>#}
{# {{ app.descriptionSmall|default('Aucune description disponible.')|raw }}#}
{# </p>#}
{# </div>#}
{# #}{# find appGroup once, used in both editable and read-only branches #}
{# {% set appGroup = data.uoas[app.id]|default(null) %}#}
{# {% if canEdit %}#}
{# <form method="POST"#}
{# action="{{ path('user_application_role', { id: data.singleUo.id }) }}">#}
{# <div class="form-group mb-3">#}
{# <label for="roles-{{ app.id }}"><b>Rôles :</b></label>#}
{# <div class="form-check">#}
{# {% if appGroup %}#}
{# {% for role in data.rolesArray %}#}
{# <input class="form-check-input" type="checkbox"#}
{# name="roles[]"#}
{# value="{{ role.id }}"#}
{# id="role-{{ role.id }}-app-{{ app.id }}"#}
{# {% if role.id in appGroup.selectedRoleIds %}checked{% endif %}>#}
{# <label class="form-check"#}
{# for="role-{{ role.id }}-app-{{ app.id }}">#}
{# {% if role.name == 'USER' %}#}
{# Accès#}
{# {% else %}#}
{# {{ role.name|capitalize }}#}
{# {% endif %}#}
{# </label>#}
{# {% endfor %}#}
{# {% else %}#}
{# <p class="text-muted">Aucun rôle défini pour cette application.</p>#}
{# {% endif %}#}
{# </div>#}
{# <button type="submit" name="appId" value="{{ app.id }}"#}
{# class="btn btn-primary mt-2">#}
{# Sauvegarder#}
{# </button>#}
{# </div>#}
{# </form>#}
{# {% else %}#}
{# <div class="form-group mb-3">#}
{# <label for="roles-{{ app.id }}"><b>Rôles :</b></label>#}
{# <div class="form-check">#}
{# {% if appGroup %}#}
{# {% for role in data.rolesArray %}#}
{# <input class="form-check-input" type="checkbox"#}
{# disabled#}
{# name="roles[]"#}
{# value="{{ role.id }}"#}
{# id="role-{{ role.id }}-app-{{ app.id }}"#}
{# {% if role.id in appGroup.selectedRoleIds %}checked{% endif %}>#}
{# <label class="form-check"#}
{# for="role-{{ role.id }}-app-{{ app.id }}">#}
{# {% if role.name == 'USER' %}#}
{# Accès#}
{# {% else %}#}
{# {{ role.name|capitalize }}#}
{# {% endif %}#}
{# </label>#}
{# {% endfor %}#}
{# {% else %}#}
{# <p class="text-muted">Aucun rôle défini pour cette application.</p>#}
{# {% endif %}#}
{# </div>#}
{# </div>#}
{# {% endif %}#}
{# </div>#}
{# </div>#}
{# </div>#}
{# {% endfor %}#}
{# </div>#}
{# </div>#}
</div>
</div>
</div>

View File

@ -1,31 +1,80 @@
{% block body %}
<div class="card no-header-bg border-0 ">
<div class="card no-header-bg border-0"
data-controller="user"
data-user-org-id-value="{{organizationId}}">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex gap-2">
{% if user.pictureUrl is not empty %}
<img src="{{asset(user.pictureUrl)}}" alt="user" class="rounded-circle"
style="width:40px; height:40px;">
<img src="{{ asset(user.pictureUrl) }}" alt="user" class="rounded-circle" style="width:40px; height:40px;">
{% endif %}
<div class="card-title ">
<div class="card-title">
<h2>{{ user.surname|capitalize }} {{ user.name|capitalize }}</h2>
</div>
</div>
<div class="d-flex gap-2">
<a href="{{ path('user_edit', {'id': user.id}) }}"
class="btn btn-primary">Modifier</a>
{# Trigger the edit modal with the user ID #}
<button type="button" class="btn btn-primary"
data-action="click->user#openEditUserModal"
data-id="{{ user.id }}">
Modifier
</button>
</div>
</div>
<div class="card-body ms-4">
<p><b>Email: </b>{{ user.email }}</p>
<p><b>Dernière connection: </b>{{ user.lastConnection|date('d/m/Y') }}
à {{ user.lastConnection|date('H:m:s') }} </p>
<p><b>Dernière connection: </b>{{ user.lastConnection|date('d/m/Y') }} à {{ user.lastConnection|date('H:m') }}</p>
<p><b>Compte crée le: </b>{{ user.createdAt|date('d/m/Y') }}</p>
<p><b>Numéro de téléphone: </b>{{ user.phoneNumber ? user.phoneNumber : 'Non renseigné' }}</p>
</div>
{# Reusable Edit Modal #}
<div class="modal fade" id="editUserModal" tabindex="-1" aria-hidden="true" data-user-target="modal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modifier l'utilisateur</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form data-action="submit->user#submitEditUser">
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Email*</label>
<input type="email" name="email" class="form-control" data-user-target="emailInput" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Numéro de téléphone</label>
<input type="text" name="phoneNumber" class="form-control" data-user-target="phoneInput">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Prénom*</label>
<input type="text" name="name" class="form-control" data-user-target="nameInput" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Nom*</label>
<input type="text" name="surname" class="form-control" data-user-target="surnameInput" required>
</div>
</div>
<input type="hidden" name="organizationId" value="{{ organizationId }}">
<hr>
<label class="form-label">**Accès aux applications**</label>
<div class="row" data-user-target="appList">
{# Checkboxes loaded here #}
</div>
</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">Enregistrer les modifications</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -5,7 +5,7 @@ namespace App\Tests\Controller;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use App\Service\AwsService;
use App\Tests\Functional\AbstractFunctional;
@ -348,10 +348,10 @@ class OrganizationController extends AbstractFunctional
self::assertCount(1, $this->entityManager->getRepository(Apps::class)->findAll());
self::assertCount(1, $this->entityManager->getRepository(Roles::class)->findAll());
self::assertCount(1, $this->entityManager->getRepository(UsersOrganizations::class)->findAll());
self::assertCount(1, $this->entityManager->getRepository(UserOrganizatonApp::class)->findAll());
self::assertCount(1, $this->entityManager->getRepository(UserOrganizationApp::class)->findAll());
self::assertTrue($this->entityManager->getRepository(Organizations::class)->find($organization->getId())->isDeleted());
self::assertFalse($this->entityManager->getRepository(UsersOrganizations::class)->find($uoLink->getId())->isActive());
self::assertFalse($this->entityManager->getRepository(UserOrganizatonApp::class)->find($uoaLink->getId())->isActive());
self::assertFalse($this->entityManager->getRepository(UserOrganizationApp::class)->find($uoaLink->getId())->isActive());
self::assertSelectorNotExists('#tabulator-org');
}

View File

@ -11,7 +11,7 @@ use App\Entity\Apps;
use App\Entity\Roles;
use App\Entity\Organizations;
use App\Entity\UsersOrganizations;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Tests\Functional\AbstractFunctional;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
@ -542,7 +542,7 @@ class UserController extends AbstractFunctional
$this->client->followRedirect();
self::assertCount(2, $this->entityManager->getRepository(User::class)->findAll());
self::assertCount(1, $this->entityManager->getRepository(UsersOrganizations::class)->findAll());
self::assertCount(1, $this->entityManager->getRepository(UserOrganizatonApp::class)->findAll());
self::assertCount(1, $this->entityManager->getRepository(UserOrganizationApp::class)->findAll());
}
#[Test]

View File

@ -7,7 +7,7 @@ use App\Entity\Notification;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
@ -85,8 +85,8 @@ abstract class AbstractFunctional extends WebTestCase
return $uo;
}
protected function createUOALink(UsersOrganizations $uo, Apps $app, Roles $role): UserOrganizatonApp{
$uoa = new UserOrganizatonApp();
protected function createUOALink(UsersOrganizations $uo, Apps $app, Roles $role): UserOrganizationApp{
$uoa = new UserOrganizationApp();
$uoa->setUserOrganization($uo);
$uoa->setApplication($app);
$uoa->setRole($role);

View File

@ -6,7 +6,7 @@ use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use App\Repository\UsersOrganizationsRepository;
use App\Service\AwsService;
@ -186,7 +186,7 @@ class OrganizationsServiceTest extends TestCase
$adminRole->setName('ADMIN');
// 4. Setup UOA Logic (Proof that user is Admin of an App)
$uoa = new UserOrganizatonApp();
$uoa = new UserOrganizationApp();
$this->setEntityId($uoa, 777);
$uoa->setUserOrganization($adminUO);
$uoa->setRole($adminRole);
@ -209,7 +209,7 @@ class OrganizationsServiceTest extends TestCase
$this->entityManager->method('getRepository')->willReturnMap([
[Roles::class, $rolesRepo],
[UserOrganizatonApp::class, $uoaRepo],
[UserOrganizationApp::class, $uoaRepo],
]);
// 6. Expectations
@ -246,7 +246,7 @@ class OrganizationsServiceTest extends TestCase
$roleAdmin = new Roles();
$uoa = new UserOrganizatonApp(); // active admin link
$uoa = new UserOrganizationApp(); // active admin link
// Mocks setup
$rolesRepo = $this->createMock(EntityRepository::class);
@ -259,7 +259,7 @@ class OrganizationsServiceTest extends TestCase
$this->entityManager->method('getRepository')->willReturnMap([
[Roles::class, $rolesRepo],
[UserOrganizatonApp::class, $uoaRepo],
[UserOrganizationApp::class, $uoaRepo],
]);
// Expectations: Notification service should NEVER be called
@ -294,7 +294,7 @@ class OrganizationsServiceTest extends TestCase
$this->entityManager->method('getRepository')->willReturnMap([
[Roles::class, $rolesRepo],
[UserOrganizatonApp::class, $uoaRepo],
[UserOrganizationApp::class, $uoaRepo],
]);
// 4. Expectations: ensure NOTHING happens
@ -326,7 +326,7 @@ class OrganizationsServiceTest extends TestCase
$adminRole = new Roles();
$adminRole->setName('ADMIN');
$uoa = new UserOrganizatonApp();
$uoa = new UserOrganizationApp();
$uoa->setUserOrganization($adminUO);
$uoa->setRole($adminRole);
$uoa->setIsActive(true);
@ -342,7 +342,7 @@ class OrganizationsServiceTest extends TestCase
$this->entityManager->method('getRepository')->willReturnMap([
[Roles::class, $rolesRepo],
[UserOrganizatonApp::class, $uoaRepo],
[UserOrganizationApp::class, $uoaRepo],
]);
// 5. Dynamic Expectations

View File

@ -6,7 +6,7 @@ use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use App\Service\ActionService;
use App\Service\LoggerService;
@ -78,7 +78,7 @@ class UserOrganizationAppServiceTest extends TestCase
$uo = new UsersOrganizations(); $this->setEntityId($uo, 99);
$uoa = new UserOrganizatonApp();
$uoa = new UserOrganizationApp();
$this->setEntityId($uoa, 500);
$uoa->setApplication($app1);
$uoa->setRole($role);
@ -121,7 +121,7 @@ class UserOrganizationAppServiceTest extends TestCase
$role = new Roles();
$this->setEntityId($role, 10);
$uoa = new UserOrganizatonApp();
$uoa = new UserOrganizationApp();
$this->setEntityId($uoa, 555);
$uoa->setApplication($app);
$uoa->setRole($role);
@ -159,7 +159,7 @@ class UserOrganizationAppServiceTest extends TestCase
$app = new Apps(); $this->setEntityId($app, 1);
$role = new Roles(); $this->setEntityId($role, 1);
$realUoa = new UserOrganizatonApp();
$realUoa = new UserOrganizationApp();
$this->setEntityId($realUoa, 100);
$realUoa->setApplication($app);
$realUoa->setRole($role);
@ -209,12 +209,12 @@ class UserOrganizationAppServiceTest extends TestCase
$roleRepo->method('find')->with($roleId)->willReturn($role);
$this->entityManager->method('getRepository')->willReturnMap([
[UserOrganizatonApp::class, $uoaRepo],
[UserOrganizationApp::class, $uoaRepo],
[Roles::class, $roleRepo],
]);
// Expect creation
$this->entityManager->expects($this->once())->method('persist')->with($this->isInstanceOf(UserOrganizatonApp::class));
$this->entityManager->expects($this->once())->method('persist')->with($this->isInstanceOf(UserOrganizationApp::class));
$this->entityManager->expects($this->once())->method('flush');
$this->actionService->expects($this->once())->method('createAction');
@ -239,7 +239,7 @@ class UserOrganizationAppServiceTest extends TestCase
$role = new Roles(); $this->setEntityId($role, 30);
$role->setName('VIEWER');
$existingUoa = new UserOrganizatonApp();
$existingUoa = new UserOrganizationApp();
$this->setEntityId($existingUoa, 999);
$existingUoa->setRole($role);
$existingUoa->setApplication($app);
@ -251,7 +251,7 @@ class UserOrganizationAppServiceTest extends TestCase
$uoaRepo->method('findBy')->willReturn([$existingUoa]);
$this->entityManager->method('getRepository')->willReturnMap([
[UserOrganizatonApp::class, $uoaRepo],
[UserOrganizationApp::class, $uoaRepo],
]);
// We pass empty array [] as selected roles -> expect deactivation
@ -296,7 +296,7 @@ class UserOrganizationAppServiceTest extends TestCase
$roleRepo->method('findOneBy')->with(['name' => 'ADMIN'])->willReturn($adminRole);
$this->entityManager->method('getRepository')->willReturnMap([
[UserOrganizatonApp::class, $uoaRepo],
[UserOrganizationApp::class, $uoaRepo],
[Roles::class, $roleRepo],
]);
@ -312,7 +312,7 @@ class UserOrganizationAppServiceTest extends TestCase
// - The new ADMIN link (automatically created)
$this->entityManager->expects($this->exactly(2))
->method('persist')
->with($this->isInstanceOf(UserOrganizatonApp::class));
->with($this->isInstanceOf(UserOrganizationApp::class));
// Run
$this->service->syncRolesForUserOrganizationApp($uo, $app, ['100'], $actingUser);

View File

@ -5,7 +5,7 @@ namespace App\Tests\Service;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UserOrganizationApp;
use App\Entity\UsersOrganizations;
use App\Event\UserCreatedEvent;
use App\Service\ActionService;
@ -328,7 +328,7 @@ class UserServiceTest extends TestCase
// 4. UserOrganizatonApp mock (The link checking if they are admin active)
$uoaRepo = $this->createMock(EntityRepository::class);
$uoa = new UserOrganizatonApp();
$uoa = new UserOrganizationApp();
$uoaRepo->method('findOneBy')->willReturn($uoa); // Returns an object, so true
// Configure EntityManager to return these repos based on class
@ -336,7 +336,7 @@ class UserServiceTest extends TestCase
[User::class, $userRepo],
[UsersOrganizations::class, $uoRepo],
[Roles::class, $rolesRepo],
[UserOrganizatonApp::class, $uoaRepo],
[UserOrganizationApp::class, $uoaRepo],
]);
$result = $this->userService->isAdminOfOrganization($org);