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 <noreply@anthropic.com>
This commit is contained in:
106
frontend/components/AdminUserTab.vue
Normal file
106
frontend/components/AdminUserTab.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un utilisateur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 overflow-x-auto rounded-lg border border-neutral-200">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold text-neutral-700">Nom d'utilisateur</th>
|
||||||
|
<th class="px-4 py-3 font-semibold text-neutral-700">Rôles</th>
|
||||||
|
<th class="px-4 py-3 font-semibold text-neutral-700">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="cursor-pointer border-b border-neutral-100 hover:bg-neutral-50"
|
||||||
|
@click="openEdit(item)"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 font-semibold text-primary-500">{{ item.username }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
v-for="role in item.roles"
|
||||||
|
:key="role"
|
||||||
|
class="mr-1 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-700"
|
||||||
|
>
|
||||||
|
{{ role }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<button
|
||||||
|
class="text-red-500 hover:text-red-700"
|
||||||
|
@click.stop="handleDelete(item.id)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:delete-outline" size="20" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="items.length === 0 && !isLoading">
|
||||||
|
<td colspan="3" class="px-4 py-8 text-center text-neutral-400">
|
||||||
|
Aucun utilisateur trouvé.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:item="selectedItem"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import { useUserService } from '~/services/users'
|
||||||
|
|
||||||
|
const { getAll, remove } = useUserService()
|
||||||
|
const items = ref<UserData[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedItem = ref<UserData | null>(null)
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
items.value = await getAll()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedItem.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: UserData) {
|
||||||
|
selectedItem.value = item
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await remove(id)
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
133
frontend/components/UserDrawer.vue
Normal file
133
frontend/components/UserDrawer.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'">
|
||||||
|
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.username"
|
||||||
|
label="Nom d'utilisateur"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
||||||
|
@blur="touched.username = true"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.password"
|
||||||
|
label="Mot de passe"
|
||||||
|
input-class="w-full"
|
||||||
|
type="password"
|
||||||
|
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||||
|
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
||||||
|
@blur="touched.password = true"
|
||||||
|
/>
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="text-sm font-semibold text-neutral-700">Rôles</label>
|
||||||
|
<div class="mt-2 flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
v-for="role in availableRoles"
|
||||||
|
:key="role"
|
||||||
|
class="flex items-center gap-2 text-sm text-neutral-700"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.roles"
|
||||||
|
type="checkbox"
|
||||||
|
:value="role"
|
||||||
|
class="rounded border-neutral-300"
|
||||||
|
/>
|
||||||
|
{{ role }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||||
|
import { useUserService } from '~/services/users'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
item: UserData | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.item)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
roles: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({
|
||||||
|
username: false,
|
||||||
|
password: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
if (props.item) {
|
||||||
|
form.username = props.item.username ?? ''
|
||||||
|
form.password = ''
|
||||||
|
form.roles = [...props.item.roles]
|
||||||
|
} else {
|
||||||
|
form.username = ''
|
||||||
|
form.password = ''
|
||||||
|
form.roles = ['ROLE_USER']
|
||||||
|
}
|
||||||
|
touched.username = false
|
||||||
|
touched.password = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { create, update } = useUserService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.username = true
|
||||||
|
touched.password = true
|
||||||
|
if (!form.username.trim()) return
|
||||||
|
if (!isEditing.value && !form.password) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: UserWrite = {
|
||||||
|
username: form.username.trim(),
|
||||||
|
roles: form.roles,
|
||||||
|
}
|
||||||
|
if (form.password) {
|
||||||
|
payload.password = form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.item) {
|
||||||
|
await update(props.item.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
export type UserData = {
|
export type UserData = {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
'@id'?: string
|
||||||
roles: string[]
|
username: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserWrite = {
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
roles: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
32
frontend/services/users.ts
Normal file
32
frontend/services/users.ts
Normal file
@@ -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<UserData[]> {
|
||||||
|
const data = await api.get<HydraCollection<UserData>>('/users')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: UserWrite): Promise<UserData> {
|
||||||
|
return api.post<UserData>('/users', payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'users.created',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, payload: Partial<UserWrite>): Promise<UserData> {
|
||||||
|
return api.patch<UserData>(`/users/${id}`, payload as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'users.updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await api.delete(`/users/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'users.deleted',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, create, update, remove }
|
||||||
|
}
|
||||||
@@ -5,9 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use App\State\MeProvider;
|
use App\State\MeProvider;
|
||||||
|
use App\State\UserPasswordHasherProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
@@ -22,7 +27,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
provider: MeProvider::class,
|
provider: MeProvider::class,
|
||||||
normalizationContext: ['groups' => ['me:read']],
|
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\Entity(repositoryClass: UserRepository::class)]
|
||||||
#[ORM\Table(name: '`user`')]
|
#[ORM\Table(name: '`user`')]
|
||||||
@@ -31,19 +46,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['me:read'])]
|
#[Groups(['me:read', 'task:read', 'user:list'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 180, unique: true)]
|
#[ORM\Column(length: 180, unique: true)]
|
||||||
#[Groups(['me:read'])]
|
#[Groups(['me:read', 'task:read', 'user:list', 'user:write'])]
|
||||||
private ?string $username = null;
|
private ?string $username = null;
|
||||||
|
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['me:read'])]
|
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||||
private array $roles = [];
|
private array $roles = [];
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
#[Groups(['user:write'])]
|
||||||
private ?string $password = null;
|
private ?string $password = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
|||||||
40
src/State/UserPasswordHasherProcessor.php
Normal file
40
src/State/UserPasswordHasherProcessor.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<User, User>
|
||||||
|
*/
|
||||||
|
final readonly class UserPasswordHasherProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param ProcessorInterface<User, User> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user