From 76f1363457eac2131d4d86af58da65911710ecbe Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 16 Feb 2026 07:19:05 +0000 Subject: [PATCH] =?UTF-8?q?[#321]=20Gestion=20des=20r=C3=B4les=20dans=20l'?= =?UTF-8?q?application=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Numéro du ticket | Titre du ticket | |------------------|-----------------| | #321 | Gestion des rôles dans l'application | ## Description de la PR [#321] Gestion des rôles dans l'application ## Modification du .env ## Check list - [x] Pas de régression - [ ] TU/TI/TF rédigée - [x] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: https://gitea.malio.fr/MALIO-DEV/SIRH/pulls/2 Co-authored-by: tristan Co-committed-by: tristan --- .idea/data_source_mapping.xml | 6 + frontend/i18n/locales/fr.json | 10 + frontend/layouts/default.vue | 80 ++-- frontend/middleware/admin.ts | 12 + frontend/pages/users.vue | 464 ++++++++++++++++++++++ frontend/services/dto/user-data.ts | 1 + frontend/services/dto/user.ts | 8 + frontend/services/user-site-roles.ts | 38 ++ frontend/services/users.ts | 57 +++ migrations/Version20260211120000.php | 33 ++ migrations/Version20260212120000.php | 30 ++ src/ApiResource/AbsencePrint.php | 3 +- src/Entity/Absence.php | 4 +- src/Entity/AbsenceType.php | 6 +- src/Entity/Employee.php | 4 +- src/Entity/Site.php | 6 +- src/Entity/User.php | 108 +++++ src/Entity/UserSiteRole.php | 101 +++++ src/State/UserPasswordHasherProcessor.php | 34 ++ 19 files changed, 965 insertions(+), 40 deletions(-) create mode 100644 .idea/data_source_mapping.xml create mode 100644 frontend/middleware/admin.ts create mode 100644 frontend/pages/users.vue create mode 100644 frontend/services/dto/user.ts create mode 100644 frontend/services/user-site-roles.ts create mode 100644 frontend/services/users.ts create mode 100644 migrations/Version20260211120000.php create mode 100644 migrations/Version20260212120000.php create mode 100644 src/Entity/UserSiteRole.php create mode 100644 src/State/UserPasswordHasherProcessor.php diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml new file mode 100644 index 0000000..99c0193 --- /dev/null +++ b/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 22845f6..4715c24 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -31,6 +31,11 @@ "create": "Impossible de créer l'absence.", "update": "Impossible de mettre à jour l'absence.", "delete": "Impossible de supprimer l'absence." + }, + "user": { + "create": "Impossible de créer l'utilisateur.", + "update": "Impossible de mettre à jour l'utilisateur.", + "delete": "Impossible de supprimer l'utilisateur." } }, "success": { @@ -57,6 +62,11 @@ "create": "Absence créée.", "update": "Absence mise à jour.", "delete": "Absence supprimée." + }, + "user": { + "create": "Utilisateur créé.", + "update": "Utilisateur mis à jour.", + "delete": "Utilisateur supprimé." } } } diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 73db89f..87cecd7 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -6,41 +6,50 @@ Logo
@@ -65,6 +74,7 @@ diff --git a/frontend/services/dto/user-data.ts b/frontend/services/dto/user-data.ts index 7febbcd..bfe1fec 100644 --- a/frontend/services/dto/user-data.ts +++ b/frontend/services/dto/user-data.ts @@ -1,4 +1,5 @@ export type UserData = { id: number username: string + roles: string[] } diff --git a/frontend/services/dto/user.ts b/frontend/services/dto/user.ts new file mode 100644 index 0000000..6c36012 --- /dev/null +++ b/frontend/services/dto/user.ts @@ -0,0 +1,8 @@ +import type { Employee } from './employee' + +export type User = { + id: number + username: string + roles: string[] + employee?: Employee | null +} diff --git a/frontend/services/user-site-roles.ts b/frontend/services/user-site-roles.ts new file mode 100644 index 0000000..9871ff9 --- /dev/null +++ b/frontend/services/user-site-roles.ts @@ -0,0 +1,38 @@ +import { extractItems } from '~/utils/api' + +export const createUserSiteRole = async (payload: { + userId: number + siteId: number + role: string +}) => { + const api = useApi() + return api.post('/user_site_roles', { + user: `/api/users/${payload.userId}`, + site: `/api/sites/${payload.siteId}`, + role: payload.role + }, { + toast: false + }) +} + +export type UserSiteRole = { + id: number + user: { id: number } + site: { id: number; name?: string } + role: string +} + +export const listUserSiteRoles = async () => { + const api = useApi() + const data = await api.get( + '/user_site_roles', + {}, + { toast: false } + ) + return extractItems(data) +} + +export const deleteUserSiteRole = async (id: number) => { + const api = useApi() + return api.delete(`/user_site_roles/${id}`, {}, { toast: false }) +} diff --git a/frontend/services/users.ts b/frontend/services/users.ts new file mode 100644 index 0000000..d223da3 --- /dev/null +++ b/frontend/services/users.ts @@ -0,0 +1,57 @@ +import type { User } from './dto/user' +import { extractItems } from '~/utils/api' + +export const listUsers = async () => { + const api = useApi() + const data = await api.get( + '/users', + {}, + { toast: false } + ) + return extractItems(data) +} + +export const createUser = async (payload: { + username: string + plainPassword: string + roles: string[] + employeeId?: number | null +}) => { + const api = useApi() + return api.post( + '/users', + { + username: payload.username, + plainPassword: payload.plainPassword, + roles: payload.roles, + employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null + }, + { + toastSuccessKey: 'success.user.create', + toastErrorKey: 'errors.user.create' + } + ) +} + +export const updateUser = async (id: number, payload: { + username: string + plainPassword?: string + roles: string[] + employeeId?: number | null +}) => { + const api = useApi() + const body: Record = { + username: payload.username, + roles: payload.roles, + employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null + } + + if (payload.plainPassword) { + body.plainPassword = payload.plainPassword + } + + return api.patch(`/users/${id}`, body, { + toastSuccessKey: 'success.user.update', + toastErrorKey: 'errors.user.update' + }) +} diff --git a/migrations/Version20260211120000.php b/migrations/Version20260211120000.php new file mode 100644 index 0000000..fc8d3a7 --- /dev/null +++ b/migrations/Version20260211120000.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE user_site_roles (id SERIAL NOT NULL, user_id INT NOT NULL, site_id INT NOT NULL, role VARCHAR(50) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_USER_SITE_ROLES_USER ON user_site_roles (user_id)'); + $this->addSql('CREATE INDEX IDX_USER_SITE_ROLES_SITE ON user_site_roles (site_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_USER_SITE_ROLES_USER_SITE_ROLE ON user_site_roles (user_id, site_id, role)'); + $this->addSql('ALTER TABLE user_site_roles ADD CONSTRAINT FK_USER_SITE_ROLES_USER FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE user_site_roles ADD CONSTRAINT FK_USER_SITE_ROLES_SITE FOREIGN KEY (site_id) REFERENCES sites (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE user_site_roles DROP CONSTRAINT FK_USER_SITE_ROLES_USER'); + $this->addSql('ALTER TABLE user_site_roles DROP CONSTRAINT FK_USER_SITE_ROLES_SITE'); + $this->addSql('DROP TABLE user_site_roles'); + } +} diff --git a/migrations/Version20260212120000.php b/migrations/Version20260212120000.php new file mode 100644 index 0000000..2ba06df --- /dev/null +++ b/migrations/Version20260212120000.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE users ADD employee_id INT DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_USERS_EMPLOYEE ON users (employee_id)'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_USERS_EMPLOYEE FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE users DROP CONSTRAINT FK_USERS_EMPLOYEE'); + $this->addSql('DROP INDEX UNIQ_USERS_EMPLOYEE'); + $this->addSql('ALTER TABLE users DROP COLUMN employee_id'); + } +} diff --git a/src/ApiResource/AbsencePrint.php b/src/ApiResource/AbsencePrint.php index 331c41a..97cdf93 100644 --- a/src/ApiResource/AbsencePrint.php +++ b/src/ApiResource/AbsencePrint.php @@ -18,7 +18,8 @@ use App\State\AbsencePrintProvider; new QueryParameter(key: 'from', required: true), new QueryParameter(key: 'to', required: true), new QueryParameter(key: 'sites', required: false), - ] + ], + security: "is_granted('ROLE_ADMIN')" ), ] )] diff --git a/src/Entity/Absence.php b/src/Entity/Absence.php index 1de62fc..56dbb03 100644 --- a/src/Entity/Absence.php +++ b/src/Entity/Absence.php @@ -20,7 +20,9 @@ use Symfony\Component\Serializer\Attribute\Groups; ], denormalizationContext: [ 'datetime_format' => 'Y-m-d', - ] + ], + paginationEnabled: false, + security: "is_granted('ROLE_ADMIN')", )] #[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])] #[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])] diff --git a/src/Entity/AbsenceType.php b/src/Entity/AbsenceType.php index 119bf1d..d311d75 100644 --- a/src/Entity/AbsenceType.php +++ b/src/Entity/AbsenceType.php @@ -8,7 +8,11 @@ use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; -#[ApiResource(normalizationContext: ['groups' => ['absence_type:read']])] +#[ApiResource( + normalizationContext: ['groups' => ['absence_type:read']], + paginationEnabled: false, + security: "is_granted('ROLE_ADMIN')" +)] #[ORM\Entity] #[ORM\Table(name: 'absence_types')] class AbsenceType diff --git a/src/Entity/Employee.php b/src/Entity/Employee.php index 95ace70..452d36e 100644 --- a/src/Entity/Employee.php +++ b/src/Entity/Employee.php @@ -11,7 +11,9 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( normalizationContext: ['groups' => ['employee:read', 'site:read']], - denormalizationContext: ['groups' => ['employee:write']] + denormalizationContext: ['groups' => ['employee:write']], + paginationEnabled: false, + security: "is_granted('ROLE_ADMIN')" )] #[ORM\Entity] #[ORM\Table(name: 'employees')] diff --git a/src/Entity/Site.php b/src/Entity/Site.php index 7b4f584..6dff18a 100644 --- a/src/Entity/Site.php +++ b/src/Entity/Site.php @@ -8,7 +8,11 @@ use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; -#[ApiResource(normalizationContext: ['groups' => ['site:read']])] +#[ApiResource( + normalizationContext: ['groups' => ['site:read']], + paginationEnabled: false, + security: "is_granted('ROLE_ADMIN')" +)] #[ORM\Entity] #[ORM\Table(name: 'sites')] class Site diff --git a/src/Entity/User.php b/src/Entity/User.php index f0e17f0..329935a 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -4,12 +4,20 @@ declare(strict_types=1); namespace App\Entity; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; use App\State\CurrentUserProvider; +use App\State\UserPasswordHasherProcessor; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ @@ -19,6 +27,29 @@ use Symfony\Component\Security\Core\User\UserInterface; security: "is_granted('ROLE_USER')", provider: CurrentUserProvider::class ), + new GetCollection( + normalizationContext: ['groups' => ['user:read', 'employee:read', 'site:read']], + security: "is_granted('ROLE_ADMIN')" + ), + new Get( + uriTemplate: '/users/{id}', + normalizationContext: ['groups' => ['user:read', 'employee:read', 'site:read']], + security: "is_granted('ROLE_ADMIN')" + ), + new Post( + denormalizationContext: ['groups' => ['user:write']], + normalizationContext: ['groups' => ['user:read']], + security: "is_granted('ROLE_ADMIN')", + processor: UserPasswordHasherProcessor::class + ), + new Patch( + uriTemplate: '/users/{id}', + paginationEnabled: false, + normalizationContext: ['groups' => ['user:read']], + denormalizationContext: ['groups' => ['user:write']], + security: "is_granted('ROLE_ADMIN')", + processor: UserPasswordHasherProcessor::class + ), ] )] #[ORM\Entity] @@ -29,17 +60,40 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] + #[Groups(['user:read'])] private ?int $id = null; #[ORM\Column(type: 'string', length: 180)] + #[Groups(['user:read', 'user:write'])] private string $username = ''; #[ORM\Column(type: 'json')] + #[Groups(['user:write'])] private array $roles = []; #[ORM\Column(type: 'string')] private string $password = ''; + #[Groups(['user:write'])] + private string $plainPassword = ''; + + #[ApiProperty(readableLink: true)] + #[ORM\OneToOne(targetEntity: Employee::class)] + #[ORM\JoinColumn(nullable: true, unique: true)] + #[Groups(['user:read', 'user:write'])] + private ?Employee $employee = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'user', targetEntity: UserSiteRole::class, orphanRemoval: true)] + private Collection $siteRoles; + + public function __construct() + { + $this->siteRoles = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -65,6 +119,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface /** * @return list */ + #[Groups(['user:read'])] public function getRoles(): array { $roles = $this->roles; @@ -95,5 +150,58 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getPlainPassword(): string + { + return $this->plainPassword; + } + + public function setPlainPassword(string $plainPassword): self + { + $this->plainPassword = $plainPassword; + + return $this; + } + + public function getEmployee(): ?Employee + { + return $this->employee; + } + + public function setEmployee(?Employee $employee): self + { + $this->employee = $employee; + + return $this; + } + + /** + * @return Collection + */ + public function getSiteRoles(): Collection + { + return $this->siteRoles; + } + + public function addSiteRole(UserSiteRole $siteRole): self + { + if (!$this->siteRoles->contains($siteRole)) { + $this->siteRoles->add($siteRole); + $siteRole->setUser($this); + } + + return $this; + } + + public function removeSiteRole(UserSiteRole $siteRole): self + { + if ($this->siteRoles->removeElement($siteRole)) { + if ($siteRole->getUser() === $this) { + $siteRole->setUser(null); + } + } + + return $this; + } + public function eraseCredentials(): void {} } diff --git a/src/Entity/UserSiteRole.php b/src/Entity/UserSiteRole.php new file mode 100644 index 0000000..99f480b --- /dev/null +++ b/src/Entity/UserSiteRole.php @@ -0,0 +1,101 @@ + ['user_site_role:read', 'site:read', 'user:read']], + security: "is_granted('ROLE_ADMIN')" + ), + new Post( + uriTemplate: '/user_site_roles', + denormalizationContext: ['groups' => ['user_site_role:write']], + security: "is_granted('ROLE_ADMIN')" + ), + new Delete( + uriTemplate: '/user_site_roles/{id}', + security: "is_granted('ROLE_ADMIN')" + ), + ], + paginationEnabled: false, +)] +#[ORM\Entity()] +#[ORM\Table(name: 'user_site_roles')] +#[ORM\UniqueConstraint(name: 'uniq_user_site_role', fields: ['user', 'site', 'role'])] +class UserSiteRole +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + #[Groups(['user_site_role:read'])] + private ?int $id = null; + + #[ApiProperty(readableLink: true)] + #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'siteRoles')] + #[ORM\JoinColumn(nullable: false)] + #[Groups(['user_site_role:read', 'user_site_role:write'])] + private ?User $user = null; + + #[ApiProperty(readableLink: true)] + #[ORM\ManyToOne(targetEntity: Site::class)] + #[ORM\JoinColumn(nullable: false)] + #[Groups(['user_site_role:read', 'user_site_role:write'])] + private ?Site $site = null; + + #[ORM\Column(type: 'string', length: 50)] + #[Groups(['user_site_role:read', 'user_site_role:write'])] + private string $role = ''; + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): self + { + $this->user = $user; + + return $this; + } + + public function getSite(): ?Site + { + return $this->site; + } + + public function setSite(?Site $site): self + { + $this->site = $site; + + return $this; + } + + public function getRole(): string + { + return $this->role; + } + + public function setRole(string $role): self + { + $this->role = $role; + + return $this; + } +} diff --git a/src/State/UserPasswordHasherProcessor.php b/src/State/UserPasswordHasherProcessor.php new file mode 100644 index 0000000..306ce99 --- /dev/null +++ b/src/State/UserPasswordHasherProcessor.php @@ -0,0 +1,34 @@ +getPlainPassword()) { + $hashed = $this->passwordHasher->hashPassword($data, $data->getPlainPassword()); + $data->setPassword($hashed); + $data->setPlainPassword(''); + } + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } +}