From 608eeaa450b3fbffd35f05ed5d6644a1b39209d2 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 12:05:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(audit)=20:=20filtres=20Employ=C3=A9=20et?= =?UTF-8?q?=20Utilisateur=20en=20champ=20texte=20(recherche=20libre)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Employé = recherche partielle sur nom/prénom (nouveau filtre back 'employee', LIKE via join) ; Utilisateur = recherche partielle sur username. Remplace les selects par des champs texte. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- doc/audit-logging.md | 4 +- frontend/composables/useAuditLogsList.ts | 43 +++++-------------- frontend/pages/audit-logs.vue | 12 +----- frontend/services/audit-logs.ts | 4 +- src/ApiResource/AuditLogResource.php | 1 + src/Repository/AuditLogRepository.php | 13 +++++- .../AuditLogReadRepositoryInterface.php | 2 + src/State/AuditLogProvider.php | 5 ++- tests/State/AuditLogProviderTest.php | 10 +++-- 10 files changed, 40 insertions(+), 56 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4792cde..7a7ca49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,7 +209,7 @@ - `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically - Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB) - **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. **CORS** : `X-Device-Id` doit rester dans `nelmio_cors.allow_headers` (front/API cross-origin → préflight, sinon le navigateur bloque toutes les requêtes). Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`. -- **Écran Journal refondu** (`frontend/pages/audit-logs.vue` + `useAuditLogsList`) : tableau en `MalioDataTable` (1er usage SIRH), **drawer de filtre** façon STARSEED (`MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, badge compteur, Réinitialiser/Appliquer), **drawer de détail** au clic ligne. Filtres backend : `username`/`ip`/`device` (LIKE insensible casse), `entityType[]`/`action[]` (IN), `perPage` (10/25/50/100, défaut 10). Filtres **employé** et **utilisateur** = `MalioSelect` (listes employés / comptes via `listUsers`). Logique dans `useAuditLogsList` ; libellés FR en dur ; filtres hors URL. Provider/`AuditLogReadRepositoryInterface`/repository portent les nouveaux critères. +- **Écran Journal refondu** (`frontend/pages/audit-logs.vue` + `useAuditLogsList`) : tableau en `MalioDataTable` (1er usage SIRH), **drawer de filtre** façon STARSEED (`MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, badge compteur, Réinitialiser/Appliquer), **drawer de détail** au clic ligne. Filtres backend : `employee` (LIKE nom/prénom de l'employé affecté, via join), `username`/`ip`/`device` (LIKE insensible casse), `entityType[]`/`action[]` (IN), `perPage` (10/25/50/100, défaut 10). Filtres du drawer = champs texte (recherche libre), période en `MalioDateRange`, type/action en cases à cocher. Logique dans `useAuditLogsList` ; libellés FR en dur ; filtres hors URL. Provider/`AuditLogReadRepositoryInterface`/repository portent les nouveaux critères. - Documentation: `doc/audit-logging.md` ## Backend Conventions diff --git a/doc/audit-logging.md b/doc/audit-logging.md index 5861a8c..a8b32ec 100644 --- a/doc/audit-logging.md +++ b/doc/audit-logging.md @@ -51,11 +51,11 @@ Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestSta ## Filtres disponibles -- Par employé (affecté) — **select** (liste des employés) +- Par employé (affecté) — champ texte, recherche partielle sur nom/prénom (insensible à la casse) - Par période (date affectée) — sélecteur de plage - Par type(s) d'entité (multi-sélection) - Par action(s) (multi-sélection) -- Par utilisateur / compte — **select** (liste des comptes ; correspondance partielle insensible à la casse côté back) +- Par utilisateur / compte — champ texte, recherche partielle (insensible à la casse) - Par IP (recherche partielle) - Par appareil (recherche partielle sur le libellé ou le device id) diff --git a/frontend/composables/useAuditLogsList.ts b/frontend/composables/useAuditLogsList.ts index 4a4dd99..59cb9c2 100644 --- a/frontend/composables/useAuditLogsList.ts +++ b/frontend/composables/useAuditLogsList.ts @@ -1,12 +1,8 @@ import { ref, computed } from 'vue' import type { AuditLog } from '~/services/dto/audit-log' import { fetchAuditLogs, type AuditLogFilters } from '~/services/audit-logs' -import { listEmployees } from '~/services/employees' -import { listUsers } from '~/services/users' type Range = { start: string, end: string } | null -type SelectOption = { value: number, text: string } -type UsernameOption = { value: string, text: string } export const useAuditLogsList = () => { const items = ref([]) @@ -15,11 +11,9 @@ export const useAuditLogsList = () => { const perPage = ref(10) const loading = ref(false) const filterOpen = ref(false) - const employeeOptions = ref([]) - const usernameOptions = ref([]) // Applied filters (drive the fetch) - const appliedEmployeeId = ref(undefined) + const appliedEmployee = ref('') const appliedRange = ref(null) const appliedEntityTypes = ref([]) const appliedActions = ref([]) @@ -28,7 +22,7 @@ export const useAuditLogsList = () => { const appliedDevice = ref('') // Draft filters (edited inside the drawer) - const draftEmployeeId = ref(undefined) + const draftEmployee = ref('') const draftRange = ref(null) const draftEntityTypes = ref([]) const draftActions = ref([]) @@ -38,7 +32,7 @@ export const useAuditLogsList = () => { const activeFilterCount = computed(() => { let n = 0 - if (appliedEmployeeId.value !== undefined) n++ + if (appliedEmployee.value.trim() !== '') n++ if (appliedRange.value?.start || appliedRange.value?.end) n++ if (appliedEntityTypes.value.length > 0) n++ if (appliedActions.value.length > 0) n++ @@ -49,7 +43,7 @@ export const useAuditLogsList = () => { }) const buildFilters = (): AuditLogFilters => ({ - employeeId: appliedEmployeeId.value, + employee: appliedEmployee.value.trim() || undefined, from: appliedRange.value?.start || undefined, to: appliedRange.value?.end || undefined, entityType: appliedEntityTypes.value.length > 0 ? [...appliedEntityTypes.value] : undefined, @@ -79,23 +73,6 @@ export const useAuditLogsList = () => { } const init = async () => { - try { - const employees = await listEmployees() - employeeOptions.value = employees.map(e => ({ - value: e.id, - text: `${e.lastName} ${e.firstName}`, - })) - } catch { - employeeOptions.value = [] - } - try { - const users = await listUsers() - usernameOptions.value = users - .map(u => ({ value: u.username, text: u.username })) - .sort((a, b) => a.text.localeCompare(b.text)) - } catch { - usernameOptions.value = [] - } await load() } @@ -111,7 +88,7 @@ export const useAuditLogsList = () => { } const openFilters = () => { - draftEmployeeId.value = appliedEmployeeId.value + draftEmployee.value = appliedEmployee.value draftRange.value = appliedRange.value ? { ...appliedRange.value } : null draftEntityTypes.value = [...appliedEntityTypes.value] draftActions.value = [...appliedActions.value] @@ -122,7 +99,7 @@ export const useAuditLogsList = () => { } const applyFilters = () => { - appliedEmployeeId.value = draftEmployeeId.value + appliedEmployee.value = draftEmployee.value appliedRange.value = draftRange.value ? { ...draftRange.value } : null appliedEntityTypes.value = [...draftEntityTypes.value] appliedActions.value = [...draftActions.value] @@ -135,14 +112,14 @@ export const useAuditLogsList = () => { } const resetFilters = () => { - draftEmployeeId.value = undefined + draftEmployee.value = '' draftRange.value = null draftEntityTypes.value = [] draftActions.value = [] draftUsername.value = '' draftIp.value = '' draftDevice.value = '' - appliedEmployeeId.value = undefined + appliedEmployee.value = '' appliedRange.value = null appliedEntityTypes.value = [] appliedActions.value = [] @@ -160,8 +137,8 @@ export const useAuditLogsList = () => { const toggleAction = (value: string, selected: boolean) => toggle(draftActions, value, selected) return { - items, total, page, perPage, loading, filterOpen, employeeOptions, usernameOptions, activeFilterCount, - draftEmployeeId, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice, + items, total, page, perPage, loading, filterOpen, activeFilterCount, + draftEmployee, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice, init, goToPage, setPerPage, openFilters, applyFilters, resetFilters, toggleEntityType, toggleAction, } } diff --git a/frontend/pages/audit-logs.vue b/frontend/pages/audit-logs.vue index cd85e8a..31f3052 100644 --- a/frontend/pages/audit-logs.vue +++ b/frontend/pages/audit-logs.vue @@ -63,11 +63,7 @@ - + @@ -97,11 +93,7 @@ - + diff --git a/frontend/services/audit-logs.ts b/frontend/services/audit-logs.ts index 0bd03b1..b228906 100644 --- a/frontend/services/audit-logs.ts +++ b/frontend/services/audit-logs.ts @@ -1,7 +1,7 @@ import type { AuditLog } from './dto/audit-log' export type AuditLogFilters = { - employeeId?: number + employee?: string from?: string to?: string entityType?: string[] @@ -24,7 +24,7 @@ export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise = {} - if (filters.employeeId) params.employeeId = String(filters.employeeId) + if (filters.employee && filters.employee.trim() !== '') params.employee = filters.employee.trim() if (filters.from) params.from = filters.from if (filters.to) params.to = filters.to if (filters.entityType && filters.entityType.length > 0) params['entityType[]'] = filters.entityType diff --git a/src/ApiResource/AuditLogResource.php b/src/ApiResource/AuditLogResource.php index b76d1ba..cf48077 100644 --- a/src/ApiResource/AuditLogResource.php +++ b/src/ApiResource/AuditLogResource.php @@ -16,6 +16,7 @@ use App\State\AuditLogProvider; provider: AuditLogProvider::class, parameters: [ new QueryParameter(key: 'employeeId'), + new QueryParameter(key: 'employee'), new QueryParameter(key: 'from'), new QueryParameter(key: 'to'), new QueryParameter(key: 'entityType'), diff --git a/src/Repository/AuditLogRepository.php b/src/Repository/AuditLogRepository.php index 7afde5a..6a8ba9b 100644 --- a/src/Repository/AuditLogRepository.php +++ b/src/Repository/AuditLogRepository.php @@ -30,6 +30,7 @@ final class AuditLogRepository extends ServiceEntityRepository implements AuditL ?string $username = null, ?string $ip = null, ?string $device = null, + ?string $employeeName = null, int $limit = 50, int $offset = 0, ): array { @@ -38,7 +39,7 @@ final class AuditLogRepository extends ServiceEntityRepository implements AuditL ->setMaxResults($limit) ->setFirstResult($offset) ; - $this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device); + $this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName); return $qb->getQuery()->getResult(); } @@ -52,9 +53,10 @@ final class AuditLogRepository extends ServiceEntityRepository implements AuditL ?string $username = null, ?string $ip = null, ?string $device = null, + ?string $employeeName = null, ): int { $qb = $this->createQueryBuilder('a')->select('COUNT(a.id)'); - $this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device); + $this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName); return (int) $qb->getQuery()->getSingleScalarResult(); } @@ -73,10 +75,17 @@ final class AuditLogRepository extends ServiceEntityRepository implements AuditL ?string $username, ?string $ip, ?string $device, + ?string $employeeName = null, ): void { if (null !== $employeeId) { $qb->andWhere('a.employee = :employeeId')->setParameter('employeeId', $employeeId); } + if (null !== $employeeName && '' !== $employeeName) { + $qb->join('a.employee', 'e') + ->andWhere('LOWER(e.lastName) LIKE :employeeName OR LOWER(e.firstName) LIKE :employeeName') + ->setParameter('employeeName', '%'.mb_strtolower($employeeName).'%') + ; + } if (null !== $from) { $qb->andWhere('a.affectedDate >= :from')->setParameter('from', $from); } diff --git a/src/Repository/Contract/AuditLogReadRepositoryInterface.php b/src/Repository/Contract/AuditLogReadRepositoryInterface.php index 0b8eecb..9b7b978 100644 --- a/src/Repository/Contract/AuditLogReadRepositoryInterface.php +++ b/src/Repository/Contract/AuditLogReadRepositoryInterface.php @@ -24,6 +24,7 @@ interface AuditLogReadRepositoryInterface ?string $username = null, ?string $ip = null, ?string $device = null, + ?string $employeeName = null, int $limit = 50, int $offset = 0, ): array; @@ -41,5 +42,6 @@ interface AuditLogReadRepositoryInterface ?string $username = null, ?string $ip = null, ?string $device = null, + ?string $employeeName = null, ): int; } diff --git a/src/State/AuditLogProvider.php b/src/State/AuditLogProvider.php index 390f138..b429e92 100644 --- a/src/State/AuditLogProvider.php +++ b/src/State/AuditLogProvider.php @@ -47,14 +47,15 @@ class AuditLogProvider implements ProviderInterface $username = $this->normalizeString($query->get('username')); $ip = $this->normalizeString($query->get('ip')); $device = $this->normalizeString($query->get('device')); + $employee = $this->normalizeString($query->get('employee')); $empId = $employeeId ? (int) $employeeId : null; $fromDt = $from ? new DateTimeImmutable((string) $from) : null; $toDt = $to ? new DateTimeImmutable((string) $to) : null; $offset = ($page - 1) * $perPage; - $total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device); - $logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $perPage, $offset); + $total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee); + $logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee, $perPage, $offset); $items = []; foreach ($logs as $log) { diff --git a/tests/State/AuditLogProviderTest.php b/tests/State/AuditLogProviderTest.php index 6c4c34d..ab6fb2b 100644 --- a/tests/State/AuditLogProviderTest.php +++ b/tests/State/AuditLogProviderTest.php @@ -46,6 +46,7 @@ final class AuditLogProviderTest extends TestCase $repo = $this->spyRepository(); $this->provideWith($repo, [ 'employeeId' => '5', + 'employee' => 'dupont', 'username' => 'usine', 'ip' => '10.0.', 'device' => 'android', @@ -56,6 +57,7 @@ final class AuditLogProviderTest extends TestCase ]); self::assertSame(5, $repo->findArgs['employeeId']); + self::assertSame('dupont', $repo->findArgs['employeeName']); self::assertSame('usine', $repo->findArgs['username']); self::assertSame('10.0.', $repo->findArgs['ip']); self::assertSame('android', $repo->findArgs['device']); @@ -103,16 +105,16 @@ final class AuditLogProviderTest extends TestCase public function __construct(private array $items, private int $count) {} - public function findByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null, int $limit = 50, int $offset = 0): array + public function findByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null, ?string $employeeName = null, int $limit = 50, int $offset = 0): array { - $this->findArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'limit', 'offset'); + $this->findArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'employeeName', 'limit', 'offset'); return $this->items; } - public function countByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null): int + public function countByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null, ?string $employeeName = null): int { - $this->countArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device'); + $this->countArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'employeeName'); return $this->count; }