Compare commits

..

1 Commits

Author SHA1 Message Date
Matthieu 89ce523019 feat(user) : UI archivage/désarchivage des utilisateurs côté admin
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m18s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m29s
- badge « Archivé » et libellé barré dans la liste admin
- popup de confirmation avant archivage (rappelle que c'est réversible)
- bouton de restauration (PATCH archived:false) pour les archivés
- case « Afficher les utilisateurs archivés » (filtre ?archived=true)
- masque l'action d'archivage sur son propre compte (évite le 403)
- service users : getArchived/restore, toast remove -> users.archived
- i18n FR : clés archived/restored/badge/confirmation
2026-06-26 17:08:20 +02:00
12 changed files with 170 additions and 190 deletions
-4
View File
@@ -125,10 +125,6 @@ services:
tags: tags:
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist } - { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
App\Module\ProjectManagement\Infrastructure\EventListener\ProjectDefaultWorkflowListener:
tags:
- { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist }
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor: App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.45' app.version: '0.4.44'
+82 -6
View File
@@ -11,15 +11,33 @@
/> />
</div> </div>
<div class="mt-4">
<MalioCheckbox
v-model="showArchived"
:label="$t('users.showArchived')"
:reserve-message-space="false"
/>
</div>
<DataTable <DataTable
:columns="columns" :columns="columns"
:items="items" :items="items"
:loading="isLoading" :loading="isLoading"
empty-message="Aucun utilisateur trouvé." empty-message="Aucun utilisateur trouvé."
deletable
@row-click="openEdit" @row-click="openEdit"
@delete="(item) => handleDelete(item.id)"
> >
<template #cell-username="{ item }">
<span :class="{ 'text-neutral-400 line-through': item.archived }">
{{ item.username }}
</span>
<span
v-if="item.archived"
class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700"
>
{{ $t('users.archivedBadge') }}
</span>
</template>
<template #cell-roles="{ item }"> <template #cell-roles="{ item }">
<span <span
v-for="role in item.roles" v-for="role in item.roles"
@@ -29,6 +47,27 @@
{{ role }} {{ role }}
</span> </span>
</template> </template>
<template #actions="{ item }">
<MalioButtonIcon
v-if="item.archived"
icon="mdi:restore"
:aria-label="$t('users.restore')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-primary-500"
@click.stop="handleRestore(item)"
/>
<MalioButtonIcon
v-else-if="item.id !== currentUserId"
icon="mdi:delete-outline"
:aria-label="$t('users.archive')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="openArchiveConfirm(item)"
/>
</template>
</DataTable> </DataTable>
<UserDrawer <UserDrawer
@@ -36,12 +75,19 @@
:item="selectedItem" :item="selectedItem"
@saved="onSaved" @saved="onSaved"
/> />
<ConfirmArchiveUserModal
v-model="archiveConfirmOpen"
:username="userToArchive?.username ?? ''"
@confirm="confirmArchive"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -50,16 +96,27 @@ const columns: DataTableColumn[] = [
{ key: 'roles', label: 'Rôles' }, { key: 'roles', label: 'Rôles' },
] ]
const { getAll, remove } = useUserService() const { getAll, getArchived, remove, restore } = useUserService()
const authStore = useAuthStore()
const currentUserId = computed(() => authStore.user?.id)
const items = ref<UserData[]>([]) const items = ref<UserData[]>([])
const isLoading = ref(true) const isLoading = ref(true)
const drawerOpen = ref(false) const drawerOpen = ref(false)
const selectedItem = ref<UserData | null>(null) const selectedItem = ref<UserData | null>(null)
const showArchived = ref(false)
const archiveConfirmOpen = ref(false)
const userToArchive = ref<UserData | null>(null)
async function loadItems() { async function loadItems() {
isLoading.value = true isLoading.value = true
try { try {
items.value = await getAll() if (showArchived.value) {
const [active, archived] = await Promise.all([getAll(), getArchived()])
items.value = [...active, ...archived]
} else {
items.value = await getAll()
}
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -75,8 +132,23 @@ function openEdit(item: UserData) {
drawerOpen.value = true drawerOpen.value = true
} }
async function handleDelete(id: number) { function openArchiveConfirm(item: UserData) {
await remove(id) userToArchive.value = item
archiveConfirmOpen.value = true
}
async function confirmArchive() {
if (!userToArchive.value) {
return
}
await remove(userToArchive.value.id)
archiveConfirmOpen.value = false
userToArchive.value = null
await loadItems()
}
async function handleRestore(item: UserData) {
await restore(item.id)
await loadItems() await loadItems()
} }
@@ -84,6 +156,10 @@ async function onSaved() {
await loadItems() await loadItems()
} }
watch(showArchived, () => {
loadItems()
})
onMounted(() => { onMounted(() => {
loadItems() loadItems()
}) })
@@ -0,0 +1,57 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('users.archiveConfirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('users.archiveConfirmMessage', { username }) }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('users.archive')"
button-class="w-auto px-4"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
username: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
+9 -1
View File
@@ -194,8 +194,16 @@
"created": "Utilisateur créé avec succès.", "created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour avec succès.", "updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès.", "deleted": "Utilisateur supprimé avec succès.",
"archived": "Utilisateur archivé avec succès.",
"restored": "Utilisateur restauré avec succès.",
"addUser": "Ajouter un utilisateur", "addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur" "editUser": "Modifier un utilisateur",
"archivedBadge": "Archivé",
"showArchived": "Afficher les utilisateurs archivés",
"archive": "Archiver",
"restore": "Restaurer",
"archiveConfirmTitle": "Archiver l'utilisateur",
"archiveConfirmMessage": "Êtes-vous sûr de vouloir archiver l'utilisateur « {username} » ? Son compte sera désactivé (il ne pourra plus se connecter), mais ses données et son historique restent conservés. Vous pourrez le restaurer plus tard."
}, },
"admin": { "admin": {
"roles": { "roles": {
@@ -32,13 +32,6 @@
empty-option-label="Aucun client" empty-option-label="Aucun client"
group-class="w-full" group-class="w-full"
/> />
<MalioSelect
v-if="!isEditing"
v-model="form.workflowId"
:options="workflowOptions"
label="Workflow"
group-class="w-full"
/>
<div class="mt-4"> <div class="mt-4">
<ColorPicker v-model="form.color" /> <ColorPicker v-model="form.color" />
</div> </div>
@@ -131,12 +124,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project' import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/modules/directory/services/dto/client'
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea' import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack' import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
import { useProjectService } from '~/modules/project-management/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/modules/integration/services/gitea'
import { useBookStackService } from '~/modules/integration/services/bookstack' import { useBookStackService } from '~/modules/integration/services/bookstack'
@@ -183,24 +174,12 @@ const bookstackShelfOptions = computed(() =>
bookstackShelves.value.map(s => ({ label: s.name, value: s.id })) bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
) )
const { getAll: getAllWorkflows } = useWorkflowService()
const workflows = ref<Workflow[]>([])
const workflowOptions = computed(() =>
workflows.value.map(w => ({ label: w.name, value: w.id }))
)
function defaultWorkflowId(): number | null {
return (workflows.value.find(w => w.isDefault) ?? workflows.value[0])?.id ?? null
}
const form = reactive({ const form = reactive({
code: '', code: '',
name: '', name: '',
description: '', description: '',
color: '#222783', color: '#222783',
clientId: null as number | null, clientId: null as number | null,
workflowId: null as number | null,
giteaRepoFullName: null as string | null, giteaRepoFullName: null as string | null,
bookstackShelfId: null as number | null, bookstackShelfId: null as number | null,
}) })
@@ -243,7 +222,6 @@ watch(() => props.modelValue, (open) => {
form.description = '' form.description = ''
form.color = '#222783' form.color = '#222783'
form.clientId = null form.clientId = null
form.workflowId = defaultWorkflowId()
form.giteaRepoFullName = null form.giteaRepoFullName = null
form.bookstackShelfId = null form.bookstackShelfId = null
} }
@@ -291,9 +269,6 @@ async function handleSubmit() {
await update(props.project.id, payload) await update(props.project.id, payload)
} else { } else {
payload.code = form.code payload.code = form.code
if (form.workflowId) {
payload.workflow = `/api/workflows/${form.workflowId}`
}
await create(payload) await create(payload)
} }
@@ -333,15 +308,6 @@ async function handleArchiveToggle() {
} }
onMounted(async () => { onMounted(async () => {
try {
workflows.value = await getAllWorkflows()
// Si le drawer est déjà ouvert en création, pré-remplir une fois les workflows chargés.
if (props.modelValue && !props.project && !form.workflowId) {
form.workflowId = defaultWorkflowId()
}
} catch {
// Workflows indisponibles, ignore (le serveur assignera le défaut)
}
try { try {
giteaRepos.value = await listRepositories() giteaRepos.value = await listRepositories()
} catch { } catch {
+2
View File
@@ -10,6 +10,8 @@ export type UserData = {
effectivePermissions?: string[] effectivePermissions?: string[]
avatarUrl?: string | null avatarUrl?: string | null
apiToken?: string | null apiToken?: string | null
// Soft-delete flag: an archived user keeps its data but cannot log in
archived?: boolean
// HR / absence management // HR / absence management
isEmployee?: boolean isEmployee?: boolean
hireDate?: string | null hireDate?: string | null
+17 -2
View File
@@ -10,6 +10,13 @@ export function useUserService() {
return extractHydraMembers(data) return extractHydraMembers(data)
} }
// Archived users are hidden from the default collection; an admin lists
// them explicitly via the `archived` filter (handled server-side).
async function getArchived(): Promise<UserData[]> {
const data = await api.get<HydraCollection<UserData>>('/users?archived=true')
return extractHydraMembers(data)
}
async function getById(id: number): Promise<UserData> { async function getById(id: number): Promise<UserData> {
return api.get<UserData>(`/users/${id}`) return api.get<UserData>(`/users/${id}`)
} }
@@ -26,11 +33,19 @@ export function useUserService() {
}) })
} }
// Deleting a user is a soft delete server-side: the account is archived
// (kept for referential integrity) rather than removed.
async function remove(id: number): Promise<void> { async function remove(id: number): Promise<void> {
await api.delete(`/users/${id}`, {}, { await api.delete(`/users/${id}`, {}, {
toastSuccessKey: 'users.deleted', toastSuccessKey: 'users.archived',
}) })
} }
return { getAll, getById, create, update, remove } async function restore(id: number): Promise<UserData> {
return api.patch<UserData>(`/users/${id}`, { archived: false }, {
toastSuccessKey: 'users.restored',
})
}
return { getAll, getArchived, getById, create, update, remove, restore }
} }
@@ -92,11 +92,10 @@ class Project implements ProjectInterface, TimestampableInterface, BlamableInter
#[Groups(['project:read', 'project:write'])] #[Groups(['project:read', 'project:write'])]
private ?ClientInterface $client = null; private ?ClientInterface $client = null;
// workflow_id reste NOT NULL en base ; quand l'appelant n'en fournit pas,
// ProjectDefaultWorkflowListener assigne le workflow par défaut au prePersist.
#[ORM\ManyToOne(targetEntity: Workflow::class)] #[ORM\ManyToOne(targetEntity: Workflow::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')] #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
#[Groups(['project:read', 'project:write', 'task:read'])] #[Groups(['project:read', 'project:write', 'task:read'])]
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
private ?Workflow $workflow = null; private ?Workflow $workflow = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use Doctrine\ORM\Event\PrePersistEventArgs;
use RuntimeException;
/**
* Assigns the default workflow to a project when none was provided.
* Guarantees the NOT NULL workflow_id constraint across every persistence
* path (API Platform, raw API, MCP) without forcing the caller to supply one.
*/
final readonly class ProjectDefaultWorkflowListener
{
public function __construct(private WorkflowRepositoryInterface $workflowRepository) {}
public function prePersist(Project $project, PrePersistEventArgs $args): void
{
if (null !== $project->getWorkflow()) {
return;
}
$default = $this->workflowRepository->findDefault()
?? ($this->workflowRepository->findBy([], ['position' => 'ASC'], 1)[0] ?? null);
if (null === $default) {
throw new RuntimeException('Cannot create a project: no workflow exists. Seed at least one workflow first.');
}
$project->setWorkflow($default);
}
}
@@ -6,7 +6,6 @@ namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface; use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\ProjectManagement\Domain\Entity\Project; use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer; use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
@@ -16,13 +15,12 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters. Optional workflowId selects the kanban workflow; the default workflow is used when omitted.')] #[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
class CreateProjectTool class CreateProjectTool
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository, private readonly ClientRepositoryInterface $clientRepository,
private readonly WorkflowRepositoryInterface $workflowRepository,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -32,7 +30,6 @@ class CreateProjectTool
?string $description = null, ?string $description = null,
?string $color = null, ?string $color = null,
?int $clientId = null, ?int $clientId = null,
?int $workflowId = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) { if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.'); throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -55,14 +52,6 @@ class CreateProjectTool
} }
$project->setClient($client); $project->setClient($client);
} }
if (null !== $workflowId) {
$workflow = $this->workflowRepository->findById($workflowId);
if (null === $workflow) {
throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId));
}
$project->setWorkflow($workflow);
}
// When no workflow is supplied, ProjectDefaultWorkflowListener assigns the default at prePersist.
$this->entityManager->persist($project); $this->entityManager->persist($project);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ProjectManagement;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Workflow;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Vérifie que la création d'un projet fonctionne avec ou sans workflow fourni :
* - sans workflow → le workflow par défaut est assigné par le listener prePersist
* - avec workflow → le workflow choisi est conservé.
*
* @internal
*/
final class ProjectCreationWorkflowTest extends WebTestCase
{
public function testCreateProjectWithoutWorkflowAssignsDefault(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$client->loginUser($this->createManager($em));
$client->request('POST', '/api/projects', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'code' => $this->randomCode(),
'name' => 'Projet sans workflow',
]));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('workflow', $data);
self::assertNotNull($data['workflow'], 'Un workflow par défaut doit avoir été assigné.');
}
public function testCreateProjectWithExplicitWorkflow(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$workflow = self::getContainer()->get(WorkflowRepositoryInterface::class)->findDefault()
?? $em->getRepository(Workflow::class)->findOneBy([]);
self::assertInstanceOf(Workflow::class, $workflow, 'Les fixtures doivent fournir au moins un workflow.');
$client->loginUser($this->createManager($em));
$client->request('POST', '/api/projects', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'code' => $this->randomCode(),
'name' => 'Projet avec workflow',
'workflow' => '/api/workflows/'.$workflow->getId(),
]));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame($workflow->getId(), $data['workflow']['id'] ?? null);
}
private function createManager(EntityManagerInterface $em): User
{
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.manage']);
self::assertInstanceOf(Permission::class, $permission, 'Lancer app:sync-permissions pour project-management.projects.manage.');
$user = new User();
$user->setUsername('proj-create-'.uniqid());
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$user->addDirectPermission($permission);
$em->persist($user);
$em->flush();
return $user;
}
private function randomCode(): string
{
$letters = '';
for ($i = 0; $i < 6; ++$i) {
$letters .= chr(random_int(65, 90));
}
return $letters;
}
}