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).
362 lines
15 KiB
Vue
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>
|