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 @@
@@ -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);
+ }
+}