feat(permissions) : add role-based access control system

Backend:
- Add role hierarchy (ADMIN > GESTIONNAIRE > VIEWER > USER) in security.yaml
- Add password authentication on profile activation (SessionProfileController)
- Add SessionProfileAuthenticator with stateless API firewall
- Add ProfilePasswordHasher state processor for API Platform
- Add security annotations on all 18 API Platform entities
- Add denyAccessUnlessGranted on all 13 custom controllers
- Add AdminProfileController for profile/role management (/api/admin/profiles)
- Add InitProfilePasswordsCommand for initial admin setup
- Simplify SessionProfilesController to list-only (removed create/delete)

Frontend (submodule update):
- Add usePermissions composable (isAdmin, canEdit, canView, isGranted)
- Add password login modal on profiles page
- Add admin backoffice page for profile management
- Disable all form fields for ROLE_VIEWER across all edit/create pages
- Show navigation buttons for all roles, hide destructive actions for viewers
- Add readonly mode to ModelTypeForm and site/constructeur modals
- Guard /admin routes in middleware
- Configure Vite proxy for API requests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-02-26 13:37:12 +01:00
parent adc44b99d3
commit a3e440c254
36 changed files with 762 additions and 110 deletions

View File

@@ -4,18 +4,14 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Profile;
use App\Repository\ProfileRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class SessionProfilesController
{
public function __construct(
private readonly ProfileRepository $profiles,
private readonly EntityManagerInterface $entityManager
) {}
#[Route('/api/session/profiles', name: 'api_session_profiles_list', methods: ['GET'])]
@@ -29,52 +25,13 @@ final class SessionProfilesController
->getResult()
;
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
}
#[Route('/api/session/profiles', name: 'api_session_profiles_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$payload = $request->toArray();
$firstName = trim((string) ($payload['firstName'] ?? ''));
$lastName = trim((string) ($payload['lastName'] ?? ''));
if ('' === $firstName || '' === $lastName) {
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile = new Profile();
$profile->setFirstName($firstName);
$profile->setLastName($lastName);
$profile->setIsActive(true);
$this->entityManager->persist($profile);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile), JsonResponse::HTTP_CREATED);
}
#[Route('/api/session/profiles/{id}', name: 'api_session_profiles_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$profile = $this->profiles->find($id);
if (!$profile) {
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
}
$profile->setIsActive(false);
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
}
private function serializeProfile(Profile $profile): array
{
return [
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'isActive' => $profile->isActive(),
];
return new JsonResponse(array_map(static function ($profile): array {
return [
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
];
}, $items));
}
}