Files
Coltura/frontend/modules/core/pages/admin/audit-log.vue
matthieu 37eafd276c fix(audit-log) : address code review findings
Blocker
- Frontend attendait `hydra:member` / `hydra:totalItems` / `hydra:view` mais
  API Platform 4 sert `member` / `totalItems` / `view` (sans prefixe) sous
  ld+json, et un tableau plat sous json. Consequence : tableau admin et
  timeline silencieusement vides.
  Fix : `useAuditLog` force `Accept: application/ld+json` (necessaire pour
  obtenir l'objet Hydra avec pagination), types `HydraCollection`/`HydraView`
  renommes, composants accedent aux proprietes sans prefixe. Nouveau test
  fonctionnel verrouille le format.

Should-fix
- `AuditLogWriter` : ajout de `'id' => Types::GUID` pour expliciter le type
  natif PG `uuid` (fonctionnait par cast implicite mais l'intention etait
  floue).
- `AuditListener` docblock : documente que le DQL bulk DELETE/UPDATE et
  `Connection::executeStatement()` bypassent le listener (onFlush non
  appele). Piege pour les futures commandes de purge.
- `AuditLogResource` : ajout d'une regex UUID dans `requirements` de
  l'operation Get — un `GET /api/audit-logs/not-a-uuid` produisait un 500
  (cast PG rejete) au lieu d'un 404.
- `audit-log.vue` : le watcher des filtres faisait `filters.page = 1` ce
  qui declenchait le watcher de `page`, causant deux `loadEntries()` en
  parallele. Fusionne : la navigation page appelle `loadEntries()`
  directement depuis `goPrevious`/`goNext`, plus de watcher dedie.
- `useAuditLog.fetchEntityLogs` : bypass du cache `lastCollection` pour ne
  pas polluer la reference page-level quand la timeline est ouverte.
- `AuditTimeline.vue` : remplacement du `<div v-if="!canView"/>` vide par
  un `v-if` sur le wrapper — aucun DOM quand l'utilisateur n'a pas le droit.
- `AuditListenerTest` tag : retire le `_` (wildcard LIKE SQL) du prefix
  pour eviter un faux negatif de match cross-test.
- `AuditLogApiTest` : proprietes `auditConnection` / `runTag` nullable et
  tearDown guarde, sinon un echec setUp provoquait un fatal typed-property
  au lieu de propager l'exception d'origine.

Stabilite suite de tests
- `doctrine.yaml when@test` : `idle_connection_ttl: 1` sur les deux
  connexions pour eviter l'accumulation de connexions orphelines.
- tearDown des tests audit : `close()` explicite sur la connexion audit
  apres chaque test.
- `docker-compose.yml` : `max_connections=300` sur la DB dev (defaut PG=100
  insuffisant pour 220+ tests * 2 connexions/test).
2026-04-20 21:10:46 +02:00

362 lines
15 KiB
Vue

<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.auditLog.title') }}
</h1>
</div>
<!-- Filtres -->
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-5">
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_from') }}
</label>
<input
v-model="filters.performedAtAfter"
type="datetime-local"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_to') }}
</label>
<input
v-model="filters.performedAtBefore"
type="datetime-local"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.entity_type') }}
</label>
<input
v-model="filters.entityType"
type="text"
placeholder="core.User"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.user') }}
</label>
<input
v-model="filters.performedBy"
type="text"
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
{{ t('audit.filters.action') }}
</label>
<div class="mt-1 flex flex-wrap gap-2">
<label v-for="a in allActions" :key="a" class="flex items-center gap-1 text-xs">
<input
type="checkbox"
:checked="selectedActions.includes(a)"
@change="toggleAction(a)"
>
{{ t(`audit.action.${a}`) }}
</label>
</div>
</div>
</div>
<div class="mt-3 flex justify-end">
<button
type="button"
class="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-50"
@click="resetFilters"
>
{{ t('audit.filters.reset') }}
</button>
</div>
</section>
<!-- Tableau -->
<section class="mt-4 rounded border border-gray-200 bg-white overflow-hidden">
<table class="min-w-full text-sm">
<thead class="bg-tertiary-500 text-white">
<tr>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.performedAt') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.performedBy') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.entityType') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.entityId') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.action') }}
</th>
<th class="px-3 py-2 text-left font-medium">
{{ t('admin.auditLog.table.summary') }}
</th>
</tr>
</thead>
<tbody>
<template v-if="entries.length > 0">
<template v-for="entry in entries" :key="entry.id">
<tr
class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer"
@click="toggleExpand(entry.id)"
>
<td class="px-3 py-2">
{{ formatDate(entry.performedAt) }}
</td>
<td class="px-3 py-2">{{ entry.performedBy }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ entry.entityType }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ entry.entityId }}</td>
<td class="px-3 py-2">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="actionBadgeClass(entry.action)"
>
{{ t(`audit.action.${entry.action}`) }}
</span>
</td>
<td class="px-3 py-2 text-xs text-gray-600">
{{ summarize(entry) }}
</td>
</tr>
<!-- Detail expandable : diff courant + timeline complete de l'entite. -->
<tr v-if="expandedId === entry.id" class="bg-gray-50">
<td colspan="6" class="px-3 py-3">
<AuditLogDetail :entry="entry" />
<div class="mt-4 border-t border-gray-200 pt-3">
<h3 class="text-sm font-medium text-gray-700 mb-2">
{{ entry.entityType }} #{{ entry.entityId }}
</h3>
<AuditTimeline
:entity-type="entry.entityType"
:entity-id="entry.entityId"
/>
</div>
</td>
</tr>
</template>
</template>
<tr v-else-if="!loading">
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ isFiltered ? t('audit.no_results') : t('audit.empty') }}
</td>
</tr>
<tr v-else>
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</td>
</tr>
</tbody>
</table>
</section>
<!-- Pagination via hydra (view.next / view.previous) -->
<nav class="mt-3 flex items-center justify-between text-sm">
<span class="text-gray-600">
{{ totalItems }} entrée{{ totalItems > 1 ? 's' : '' }}
</span>
<div class="flex gap-2">
<button
type="button"
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-60"
:disabled="!hasPrevious || loading"
@click="goPrevious"
>
{{ t('admin.auditLog.pagination.previous') }}
</button>
<button
type="button"
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-60"
:disabled="!hasNext || loading"
@click="goNext"
>
{{ t('admin.auditLog.pagination.next') }}
</button>
</div>
</nav>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
const { t } = useI18n()
const { can } = usePermissions()
const router = useRouter()
const route = useRoute()
const { fetchLogs } = useAuditLog()
// Protection cote UI : le middleware `modules.global.ts` filtre deja les
// routes desactivees, mais si quelqu'un atterit ici sans la permission on
// renvoie sur la page admin parente plutot que de flasher un ecran vide.
if (!can('core.audit_log.view')) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
}
useHead({ title: t('admin.auditLog.title') })
const allActions = ['create', 'update', 'delete'] as const
type ActionKind = typeof allActions[number]
const filters = reactive<AuditLogFilters>({
performedAtAfter: readQuery('after'),
performedAtBefore: readQuery('before'),
entityType: readQuery('entity_type'),
performedBy: readQuery('performed_by'),
action: readQuery('action'),
page: Number(readQuery('page') ?? 1) || 1,
})
// Les checkboxes d'action fonctionnent en multi-select cote UI mais l'API
// ne supporte qu'une valeur a la fois : on combine les cases cochees en un
// seul filtre "action=X" lorsque une seule case est active. Si plusieurs ou
// zero sont cochees, on n'applique pas le filtre action (comportement =
// "toutes actions").
const selectedActions = ref<ActionKind[]>(filters.action ? [filters.action as ActionKind] : [])
const entries = ref<AuditLogEntry[]>([])
const totalItems = ref(0)
const hasPrevious = ref(false)
const hasNext = ref(false)
const loading = ref(false)
const expandedId = ref<string | null>(null)
const isFiltered = computed(() =>
Boolean(filters.performedAtAfter || filters.performedAtBefore || filters.entityType
|| filters.performedBy || filters.action),
)
function readQuery(key: string): string | undefined {
const v = route.query[key]
return typeof v === 'string' && v !== '' ? v : undefined
}
function toggleAction(action: ActionKind): void {
const idx = selectedActions.value.indexOf(action)
if (idx >= 0) selectedActions.value.splice(idx, 1)
else selectedActions.value.push(action)
filters.action = selectedActions.value.length === 1 ? selectedActions.value[0] : undefined
filters.page = 1
syncQuery()
}
function resetFilters(): void {
filters.performedAtAfter = undefined
filters.performedAtBefore = undefined
filters.entityType = undefined
filters.performedBy = undefined
filters.action = undefined
filters.page = 1
selectedActions.value = []
syncQuery()
}
async function loadEntries(): Promise<void> {
loading.value = true
try {
const data = await fetchLogs({
...filters,
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
})
entries.value = data.member ?? []
totalItems.value = data.totalItems ?? 0
const view = data.view
hasPrevious.value = Boolean(view?.previous)
hasNext.value = Boolean(view?.next)
} finally {
loading.value = false
}
}
function toIso(localDateTime: string): string {
// datetime-local n'a pas de timezone : on assume heure locale et on
// laisse le navigateur generer l'ISO via Date().
return new Date(localDateTime).toISOString()
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', {
dateStyle: 'short',
timeStyle: 'short',
})
}
function actionBadgeClass(action: string): string {
switch (action) {
case 'create': return 'bg-green-100 text-green-800'
case 'update': return 'bg-yellow-100 text-yellow-800'
case 'delete': return 'bg-red-100 text-red-800'
default: return 'bg-gray-100 text-gray-800'
}
}
function summarize(entry: AuditLogEntry): string {
const keys = Object.keys(entry.changes)
if (keys.length === 0) return '—'
if (keys.length <= 3) return keys.join(', ')
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
}
function toggleExpand(id: string): void {
expandedId.value = expandedId.value === id ? null : id
}
function goPrevious(): void {
if (!hasPrevious.value || !filters.page) return
filters.page = Math.max(1, filters.page - 1)
syncQuery()
loadEntries()
}
function goNext(): void {
if (!hasNext.value) return
filters.page = (filters.page ?? 1) + 1
syncQuery()
loadEntries()
}
// Persiste les filtres dans les query params URL pour que le reload ou le
// partage de lien retrouve le meme etat.
function syncQuery(): void {
const query: Record<string, string> = {}
if (filters.performedAtAfter) query.after = filters.performedAtAfter
if (filters.performedAtBefore) query.before = filters.performedAtBefore
if (filters.entityType) query.entity_type = filters.entityType
if (filters.performedBy) query.performed_by = filters.performedBy
if (filters.action) query.action = filters.action
if (filters.page && filters.page !== 1) query.page = String(filters.page)
router.replace({ query })
}
// Synchronisation reactive : tout changement de filtre declenche un fetch
// + reset de la pagination a la page 1. La navigation page (prev/next) ne
// passe PAS par un watcher : elle appelle `loadEntries()` directement dans
// `goPrevious`/`goNext`. Cette separation evite un double-fetch concurrent
// quand une filtre reset la page a 1 (sinon le watch de `filters.page`
// serait declenche une seconde fois en parallele).
watch(
() => [filters.performedAtAfter, filters.performedAtBefore, filters.entityType, filters.performedBy, filters.action],
() => {
filters.page = 1
syncQuery()
loadEntries()
},
)
onMounted(() => {
loadEntries()
})
</script>