feat(audit) : pagination défaut 10 + filtres employé/utilisateur en select

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:52:20 +02:00
parent e6a84af9b5
commit 06e462ef31
6 changed files with 37 additions and 13 deletions
+1 -1
View File
@@ -209,7 +209,7 @@
- `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically - `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) - 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`. - **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` (25/50/100). 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 : `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.
- Documentation: `doc/audit-logging.md` - Documentation: `doc/audit-logging.md`
## Backend Conventions ## Backend Conventions
+3 -3
View File
@@ -51,15 +51,15 @@ Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestSta
## Filtres disponibles ## Filtres disponibles
- Par employé (affecté) - Par employé (affecté) — **select** (liste des employés)
- Par période (date affectée) — sélecteur de plage - Par période (date affectée) — sélecteur de plage
- Par type(s) d'entité (multi-sélection) - Par type(s) d'entité (multi-sélection)
- Par action(s) (multi-sélection) - Par action(s) (multi-sélection)
- Par utilisateur / compte (recherche partielle, insensible à la casse) - Par utilisateur / compte — **select** (liste des comptes ; correspondance partielle insensible à la casse côté back)
- Par IP (recherche partielle) - Par IP (recherche partielle)
- Par appareil (recherche partielle sur le libellé ou le device id) - Par appareil (recherche partielle sur le libellé ou le device id)
Pagination : `perPage` (25 / 50 / 100, défaut 50) + `page`. Pagination : `perPage` (10 / 25 / 50 / 100, défaut 10) + `page`.
L'écran utilise un `MalioDataTable`, un **drawer de filtre** (bouton « Filtrer » avec compteur de filtres actifs, état brouillon/appliqué, Réinitialiser/Appliquer) et un **drawer de détail** ouvert au clic sur une ligne (méta + contexte technique IP/appareil/User-Agent/device id + diff lisible des changements). L'écran utilise un `MalioDataTable`, un **drawer de filtre** (bouton « Filtrer » avec compteur de filtres actifs, état brouillon/appliqué, Réinitialiser/Appliquer) et un **drawer de détail** ouvert au clic sur une ligne (méta + contexte technique IP/appareil/User-Agent/device id + diff lisible des changements).
+13 -2
View File
@@ -2,18 +2,21 @@ import { ref, computed } from 'vue'
import type { AuditLog } from '~/services/dto/audit-log' import type { AuditLog } from '~/services/dto/audit-log'
import { fetchAuditLogs, type AuditLogFilters } from '~/services/audit-logs' import { fetchAuditLogs, type AuditLogFilters } from '~/services/audit-logs'
import { listEmployees } from '~/services/employees' import { listEmployees } from '~/services/employees'
import { listUsers } from '~/services/users'
type Range = { start: string, end: string } | null type Range = { start: string, end: string } | null
type SelectOption = { value: number, text: string } type SelectOption = { value: number, text: string }
type UsernameOption = { value: string, text: string }
export const useAuditLogsList = () => { export const useAuditLogsList = () => {
const items = ref<AuditLog[]>([]) const items = ref<AuditLog[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
const perPage = ref(50) const perPage = ref(10)
const loading = ref(false) const loading = ref(false)
const filterOpen = ref(false) const filterOpen = ref(false)
const employeeOptions = ref<SelectOption[]>([]) const employeeOptions = ref<SelectOption[]>([])
const usernameOptions = ref<UsernameOption[]>([])
// Applied filters (drive the fetch) // Applied filters (drive the fetch)
const appliedEmployeeId = ref<number | undefined>(undefined) const appliedEmployeeId = ref<number | undefined>(undefined)
@@ -85,6 +88,14 @@ export const useAuditLogsList = () => {
} catch { } catch {
employeeOptions.value = [] 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() await load()
} }
@@ -149,7 +160,7 @@ export const useAuditLogsList = () => {
const toggleAction = (value: string, selected: boolean) => toggle(draftActions, value, selected) const toggleAction = (value: string, selected: boolean) => toggle(draftActions, value, selected)
return { return {
items, total, page, perPage, loading, filterOpen, employeeOptions, activeFilterCount, items, total, page, perPage, loading, filterOpen, employeeOptions, usernameOptions, activeFilterCount,
draftEmployeeId, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice, draftEmployeeId, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice,
init, goToPage, setPerPage, openFilters, applyFilters, resetFilters, toggleEntityType, toggleAction, init, goToPage, setPerPage, openFilters, applyFilters, resetFilters, toggleEntityType, toggleAction,
} }
+6 -2
View File
@@ -17,7 +17,7 @@
:total-items="list.total.value" :total-items="list.total.value"
:page="list.page.value" :page="list.page.value"
:per-page="list.perPage.value" :per-page="list.perPage.value"
:per-page-options="[25, 50, 100]" :per-page-options="[10, 25, 50, 100]"
empty-message="Aucune entrée trouvée." empty-message="Aucune entrée trouvée."
@row-click="openDetail" @row-click="openDetail"
@update:page="list.goToPage($event)" @update:page="list.goToPage($event)"
@@ -97,7 +97,11 @@
</MalioAccordionItem> </MalioAccordionItem>
<MalioAccordionItem title="Utilisateur / compte" value="username"> <MalioAccordionItem title="Utilisateur / compte" value="username">
<MalioInputText v-model="list.draftUsername.value" icon-name="mdi:magnify" /> <MalioSelect
v-model="list.draftUsername.value"
:options="list.usernameOptions.value"
empty-option-label="Tous"
/>
</MalioAccordionItem> </MalioAccordionItem>
<MalioAccordionItem title="IP" value="ip"> <MalioAccordionItem title="IP" value="ip">
+2 -2
View File
@@ -14,8 +14,8 @@ use Symfony\Component\HttpFoundation\RequestStack;
class AuditLogProvider implements ProviderInterface class AuditLogProvider implements ProviderInterface
{ {
private const DEFAULT_PER_PAGE = 50; private const DEFAULT_PER_PAGE = 10;
private const ALLOWED_PER_PAGE = [25, 50, 100]; private const ALLOWED_PER_PAGE = [10, 25, 50, 100];
public function __construct( public function __construct(
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
+12 -3
View File
@@ -77,13 +77,22 @@ final class AuditLogProviderTest extends TestCase
self::assertNull($repo->findArgs['actions']); self::assertNull($repo->findArgs['actions']);
} }
public function testPerPageOutOfRangeFallsBackTo50(): void public function testPerPageOutOfRangeFallsBackToDefault(): void
{ {
$repo = $this->spyRepository(); $repo = $this->spyRepository();
$response = $this->provideWith($repo, ['perPage' => '999']); $response = $this->provideWith($repo, ['perPage' => '999']);
self::assertSame(50, $repo->findArgs['limit']); self::assertSame(10, $repo->findArgs['limit']);
self::assertSame(50, json_decode((string) $response->getContent(), true)['perPage']); self::assertSame(10, json_decode((string) $response->getContent(), true)['perPage']);
}
public function testDefaultPerPageIs10(): void
{
$repo = $this->spyRepository();
$response = $this->provideWith($repo, []);
self::assertSame(10, $repo->findArgs['limit']);
self::assertSame(10, json_decode((string) $response->getContent(), true)['perPage']);
} }
private function spyRepository(array $items = [], int $count = 0): AuditLogReadRepositoryInterface private function spyRepository(array $items = [], int $count = 0): AuditLogReadRepositoryInterface