From 81797e10c071ec9d7cb7dcd6f4d0c2e974191445 Mon Sep 17 00:00:00 2001 From: matthieu Date: Mon, 9 Mar 2026 23:39:22 +0100 Subject: [PATCH] feat : add User CRUD with admin management Add User API operations (GET, POST, PATCH, DELETE) with password hashing processor, frontend service, drawer and admin tab. Co-Authored-By: Claude Opus 4.6 --- frontend/components/AdminUserTab.vue | 106 +++++++++++++++++ frontend/components/UserDrawer.vue | 133 ++++++++++++++++++++++ frontend/services/dto/user-data.ts | 13 ++- frontend/services/users.ts | 32 ++++++ src/Entity/User.php | 22 +++- src/State/UserPasswordHasherProcessor.php | 40 +++++++ 6 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 frontend/components/AdminUserTab.vue create mode 100644 frontend/components/UserDrawer.vue create mode 100644 frontend/services/users.ts create mode 100644 src/State/UserPasswordHasherProcessor.php diff --git a/frontend/components/AdminUserTab.vue b/frontend/components/AdminUserTab.vue new file mode 100644 index 0000000..8c537d6 --- /dev/null +++ b/frontend/components/AdminUserTab.vue @@ -0,0 +1,106 @@ + + + diff --git a/frontend/components/UserDrawer.vue b/frontend/components/UserDrawer.vue new file mode 100644 index 0000000..3561701 --- /dev/null +++ b/frontend/components/UserDrawer.vue @@ -0,0 +1,133 @@ + + + diff --git a/frontend/services/dto/user-data.ts b/frontend/services/dto/user-data.ts index bfe1fec..3ea999e 100644 --- a/frontend/services/dto/user-data.ts +++ b/frontend/services/dto/user-data.ts @@ -1,5 +1,12 @@ export type UserData = { - id: number - username: string - roles: string[] + id: number + '@id'?: string + username: string + roles: string[] +} + +export type UserWrite = { + username: string + password?: string + roles: string[] } diff --git a/frontend/services/users.ts b/frontend/services/users.ts new file mode 100644 index 0000000..69e02f0 --- /dev/null +++ b/frontend/services/users.ts @@ -0,0 +1,32 @@ +import type { UserData, UserWrite } from './dto/user-data' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useUserService() { + const api = useApi() + + async function getAll(): Promise { + const data = await api.get>('/users') + return extractHydraMembers(data) + } + + async function create(payload: UserWrite): Promise { + return api.post('/users', payload as Record, { + toastSuccessKey: 'users.created', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/users/${id}`, payload as Record, { + toastSuccessKey: 'users.updated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/users/${id}`, {}, { + toastSuccessKey: 'users.deleted', + }) + } + + return { getAll, create, update, remove } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index b00348f..fb40e9d 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -5,9 +5,14 @@ declare(strict_types=1); namespace App\Entity; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; use App\Repository\UserRepository; use App\State\MeProvider; +use App\State\UserPasswordHasherProcessor; use DateTimeImmutable; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -22,7 +27,17 @@ use Symfony\Component\Serializer\Attribute\Groups; provider: MeProvider::class, normalizationContext: ['groups' => ['me:read']], ), + new Get( + normalizationContext: ['groups' => ['user:list']], + ), + new GetCollection( + normalizationContext: ['groups' => ['user:list']], + ), + new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), + new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), + new Delete(security: "is_granted('ROLE_ADMIN')"), ], + denormalizationContext: ['groups' => ['user:write']], )] #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] @@ -31,19 +46,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['me:read'])] + #[Groups(['me:read', 'task:read', 'user:list'])] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] - #[Groups(['me:read'])] + #[Groups(['me:read', 'task:read', 'user:list', 'user:write'])] private ?string $username = null; /** @var list */ #[ORM\Column] - #[Groups(['me:read'])] + #[Groups(['me:read', 'user:list', 'user:write'])] private array $roles = []; #[ORM\Column] + #[Groups(['user:write'])] private ?string $password = null; #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] diff --git a/src/State/UserPasswordHasherProcessor.php b/src/State/UserPasswordHasherProcessor.php new file mode 100644 index 0000000..d4a9437 --- /dev/null +++ b/src/State/UserPasswordHasherProcessor.php @@ -0,0 +1,40 @@ + + */ +final readonly class UserPasswordHasherProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface $persistProcessor + */ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private ProcessorInterface $persistProcessor, + private UserPasswordHasherInterface $passwordHasher, + ) {} + + /** + * @param User $data + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (null !== $data->getPassword() && !str_starts_with($data->getPassword(), '$')) { + $data->setPassword( + $this->passwordHasher->hashPassword($data, $data->getPassword()) + ); + } + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } +}