fix(audit-log) : applique fixes code review PR #9

Resout les 5 findings de la review automatique + couverture ManyToMany
annoncee dans CLAUDE.md :

- AuditListener : resolution de la classe via ClassMetadata plutot que
  `$entity::class` direct (defense proxy Doctrine : sous ORM 2 les lazies
  sont des `Proxies\__CG__\...`). Test de regression via getReference().
- AuditListener : capture des modifications de collections to-many
  (OneToMany / ManyToMany) via getScheduledCollectionUpdates /
  getScheduledCollectionDeletions. Les diffs sont mergees dans le
  changeset existant ou creent une entree "update" dediee.
- AuditLogResource + Provider : filtre multi-valeurs
  `entity_type[]=X&entity_type[]=Y` (IN clause DBAL via
  ArrayParameterType::STRING), endpoint `/audit-log-entity-types` pour
  alimenter le MalioSelectCheckbox cote front.
- audit-log.vue : refonte complete. Passage a `MalioDataTable`,
  composants `Malio*` (MalioInputText, MalioSelectCheckbox, MalioButton),
  suppression complete de la persistance URL (`readQuery` / `syncQuery`
  / `route.query`). `datetime-local` conserve avec TODO pointant
  l'exception CLAUDE.md.
- AuditTimeline : fix du saut d'items 11-30. `PAGE_SIZE = 10` aligne
  avec un `itemsPerPage=10` passe au backend. Token anti-race pour
  ignorer les reponses tardives quand l'entite affichee change.
- AuditLogDetail : affichage des diffs de collections to-many (+ / -)
  dans le tableau field/old/new existant.
- logout.vue : ajout du `resetAuditLog()` au logout pour eviter qu'un
  user suivant (meme onglet) voie l'etat audit de l'ancien.
- Permission / Role / Site : marquage `#[Auditable]`.
- Version bump 0.1.32 → 0.1.34.

Tests : 228 / 228 (221 assertions → 851, dont regressions proxy + M2M).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-04-21 16:28:44 +02:00
parent a95bb6c629
commit 1505e84926
19 changed files with 1004 additions and 264 deletions

View File

@@ -88,13 +88,15 @@
"date_to": "Au",
"entity_type": "Type d'entité",
"user": "Utilisateur",
"action": "Action"
"action": "Action",
"all_actions": "Toutes les actions"
},
"detail": {
"field": "Champ",
"old_value": "Ancienne valeur",
"new_value": "Nouvelle valeur"
}
},
"detail_title": "Détail de l'entrée"
},
"success": {
"auth": {

View File

@@ -8,260 +8,271 @@
<!-- 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">
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
leur `label` flottant interne pour ne pas mixer deux patterns de label. -->
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
exposera un datetime picker. Cf. exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="block text-xs font-medium text-gray-600">
<label class="mb-1 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"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<!-- TODO(malio-ui): idem ci-dessus. -->
<div>
<label class="block text-xs font-medium text-gray-600">
<label class="mb-1 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"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
<label class="mb-1 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 class="[&>div>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedEntityTypes"
:options="entityTypeOptions"
:display-select-all="true"
:display-tag="true"
min-width="w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">
<label class="mb-1 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"
>
<MalioInputText
v-model="performedByInput"
icon-name="mdi:account-search"
input-class="text-sm"
group-class="h-10"
/>
</div>
<!-- TODO(malio-ui): remplacer par MalioSelect quand la lib
supportera de maniere fiable des options a valeur string
(cf. note Lesstime CLAUDE.md). Exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="block text-xs font-medium text-gray-600">
<label class="mb-1 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>
<select
v-model="actionValue"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
<option value="">{{ t('audit.filters.all_actions') }}</option>
<option v-for="opt in actionOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</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"
<MalioButton
variant="tertiary"
:label="t('audit.filters.reset')"
button-class="text-xs"
@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>
<MalioDataTable
class="mt-4"
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="filters.page ?? 1"
:per-page="filters.itemsPerPage ?? 10"
:per-page-options="[10, 25, 50]"
:empty-message="isFiltered ? t('audit.no_results') : t('audit.empty')"
@update:page="onPageChange"
@update:per-page="onPerPageChange"
@row-click="onRowClick"
>
<template #cell-action="{ item }">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="actionBadgeClass(item.action as string)"
>
{{ t(`audit.action.${item.action}`) }}
</span>
</template>
<template #cell-entityType="{ item }">
<span class="font-mono text-xs">{{ item.entityType }}</span>
</template>
<template #cell-entityId="{ item }">
<span class="font-mono text-xs">{{ item.entityId }}</span>
</template>
<template #cell-summary="{ item }">
<span class="text-xs text-gray-600">{{ item.summary }}</span>
</template>
</MalioDataTable>
<!-- 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>
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
<MalioDrawer
v-model="drawerOpen"
:title="drawerTitle"
drawer-class="max-w-2xl"
>
<div v-if="selectedEntry">
<AuditLogDetail :entry="selectedEntry" />
<div class="mt-4 border-t border-gray-200 pt-3">
<h3 class="text-sm font-medium text-gray-700 mb-2">
{{ selectedEntry.entityType }} #{{ selectedEntry.entityId }}
</h3>
<AuditTimeline
:entity-type="selectedEntry.entityType"
:entity-id="selectedEntry.entityId"
/>
</div>
</div>
</nav>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, 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()
const { fetchLogs, fetchEntityTypes } = 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.
// renvoie une 403 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]
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
// CLAUDE.md "Tableau : pas de persistance URL").
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,
performedAtAfter: undefined,
performedAtBefore: undefined,
entityType: undefined,
performedBy: undefined,
action: undefined,
page: 1,
itemsPerPage: 10,
})
// 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] : [])
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
const selectedEntityTypes = ref<(string | number)[]>([])
const entityTypes = ref<string[]>([])
const entityTypeOptions = computed(() =>
entityTypes.value.map(t => ({ value: t, label: t })),
)
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
// pas binder directement un `string | undefined` reactive.
const performedByInput = ref<string>('')
// Action : MalioSelect ne gere pas fiablement des options a valeur string (cf.
// note Lesstime CLAUDE.md). On utilise un `<select>` natif stylise comme les
// inputs dates pour garder un look coherent. '' = "toutes les actions".
const actionValue = ref<string>('')
const actionOptions = [
{ value: 'create', label: t('audit.action.create') },
{ value: 'update', label: t('audit.action.update') },
{ value: 'delete', label: t('audit.action.delete') },
]
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 drawerOpen = ref(false)
const selectedEntry = ref<AuditLogEntry | null>(null)
const columns = [
{ key: 'performedAt', label: t('admin.auditLog.table.performedAt') },
{ key: 'performedBy', label: t('admin.auditLog.table.performedBy') },
{ key: 'entityType', label: t('admin.auditLog.table.entityType') },
{ key: 'entityId', label: t('admin.auditLog.table.entityId') },
{ key: 'action', label: t('admin.auditLog.table.action') },
{ key: 'summary', label: t('admin.auditLog.table.summary') },
]
// Transforme chaque AuditLogEntry en ligne compatible MalioDataTable.
// On conserve `id` pour retrouver l'entry complete sur row-click.
const rows = computed(() =>
entries.value.map(entry => ({
id: entry.id,
performedAt: formatDate(entry.performedAt),
performedBy: entry.performedBy,
entityType: entry.entityType,
entityId: entry.entityId,
action: entry.action,
summary: summarize(entry),
})),
)
const drawerTitle = computed(() =>
selectedEntry.value
? `${selectedEntry.value.entityType} #${selectedEntry.value.entityId}`
: t('audit.detail_title'),
)
const isFiltered = computed(() =>
Boolean(filters.performedAtAfter || filters.performedAtBefore || filters.entityType
Boolean(filters.performedAtAfter || filters.performedAtBefore
|| (Array.isArray(filters.entityType) ? filters.entityType.length : filters.entityType)
|| filters.performedBy || filters.action),
)
function readQuery(key: string): string | undefined {
const v = route.query[key]
return typeof v === 'string' && v !== '' ? v : undefined
}
// Anti-race : chaque fetch incremente un compteur ; seul le dernier en date
// ecrit les resultats dans `entries`/`totalItems`. Evite qu'une reponse tardive
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
let requestToken = 0
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()
}
// Pendant un reset, on suspend temporairement les watchers pour ne pas
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
// et les fetchs partent quand meme. Un seul loadEntries() est appele
// explicitement apres la liberation.
let watchersSuspended = false
function resetFilters(): void {
async function resetFilters(): Promise<void> {
watchersSuspended = true
filters.performedAtAfter = undefined
filters.performedAtBefore = undefined
filters.entityType = undefined
filters.performedBy = undefined
filters.action = undefined
filters.page = 1
selectedActions.value = []
syncQuery()
selectedEntityTypes.value = []
performedByInput.value = ''
actionValue.value = ''
// Les watchers mute de Vue 3 se planifient en microtask : on attend
// leur execution avec le flag `true`, puis on libere.
await nextTick()
watchersSuspended = false
loadEntries()
}
async function loadEntries(): Promise<void> {
const token = ++requestToken
loading.value = true
try {
const data = await fetchLogs({
@@ -270,16 +281,30 @@ async function loadEntries(): Promise<void> {
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
})
// Reponse obsolete (un fetch plus recent a ete lance entre-temps) :
// on ignore le resultat pour ne pas overwrite l'etat courant.
if (token !== requestToken) return
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
if (token === requestToken) {
loading.value = false
}
}
}
// Debounce utilitaire pour le champ texte performedBy : evite un refetch a
// chaque frappe (reseau + SQL) et laisse l'utilisateur finir sa saisie.
function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout> | null = null
return ((...args: Parameters<T>) => {
if (null !== timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}) as T
}
const debouncedReload = debounce(() => loadEntries(), 300)
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().
@@ -309,53 +334,72 @@ function summarize(entry: AuditLogEntry): string {
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
}
function toggleExpand(id: string): void {
expandedId.value = expandedId.value === id ? null : id
function onRowClick(item: Record<string, unknown>): void {
const entry = entries.value.find(e => e.id === item.id)
if (entry) {
selectedEntry.value = entry
drawerOpen.value = true
}
}
function goPrevious(): void {
if (!hasPrevious.value || !filters.page) return
filters.page = Math.max(1, filters.page - 1)
syncQuery()
function onPageChange(value: number): void {
filters.page = value
loadEntries()
}
function goNext(): void {
if (!hasNext.value) return
filters.page = (filters.page ?? 1) + 1
syncQuery()
function onPerPageChange(value: number): void {
filters.itemsPerPage = value
filters.page = 1
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 })
}
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
watch(selectedEntityTypes, values => {
if (watchersSuspended) return
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
filters.page = 1
loadEntries()
})
// 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).
// Sync select action natif -> filters.action.
watch(actionValue, value => {
if (watchersSuspended) return
filters.action = value === '' ? undefined : value
filters.page = 1
loadEntries()
})
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
// refetch par caractere. Le reset passe par debouncedReload egalement pour
// coalescer si plusieurs watchers tirent en meme temps.
watch(performedByInput, value => {
if (watchersSuspended) return
filters.performedBy = value === '' ? undefined : value
filters.page = 1
debouncedReload()
})
// Synchronisation reactive : tout changement de dates declenche un fetch +
// reset de la pagination a la page 1.
watch(
() => [filters.performedAtAfter, filters.performedAtBefore, filters.entityType, filters.performedBy, filters.action],
() => [filters.performedAtAfter, filters.performedAtBefore],
() => {
if (watchersSuspended) return
filters.page = 1
syncQuery()
loadEntries()
},
)
onMounted(() => {
loadEntries()
onMounted(async () => {
// Charge les entity types en parallele de la liste principale : un
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
try {
entityTypes.value = await fetchEntityTypes()
} catch {
entityTypes.value = []
}
await loadEntries()
})
</script>

View File

@@ -11,6 +11,7 @@ const auth = useAuthStore()
const { resetSidebar } = useSidebar()
const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog()
onMounted(async () => {
try {
@@ -18,13 +19,14 @@ onMounted(async () => {
} finally {
// Les resets sont garantis meme si auth.logout() rejette : eviter
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Les trois fonctions reset sont synchrones et ne
// l'ancien. Toutes les fonctions reset sont synchrones et ne
// peuvent pas throw (juste des assignations reactives).
// navigateTo est dans le finally pour garantir la redirection
// meme si auth.logout() lance une exception (ex: reseau coupé).
resetSidebar()
resetModules()
resetCurrentSite()
resetAuditLog()
await navigateTo('/login')
}
})

View File

@@ -10,6 +10,9 @@
</p>
<div v-if="entry.action === 'update'">
<!-- Tableau de comparaison field/old/new. MalioDataTable n'est
pas adapte ici : cas presentationnel non-paginable (cf.
exception documentee dans CLAUDE.md). -->
<table class="min-w-full border border-gray-200 text-xs">
<thead class="bg-gray-100">
<tr>
@@ -24,6 +27,20 @@
<td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td>
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
</tr>
<!-- Modifications de collections to-many : shape different
{ added: [ids], removed: [ids] } affiche + et - sur
la meme ligne pour garder une colonne field unique. -->
<tr v-for="(diff, field) in collectionDiff" :key="`col-${field}`" class="border-t border-gray-200">
<td class="px-2 py-1 font-mono">{{ field }}</td>
<td class="px-2 py-1 text-red-700">
<span v-if="diff.removed.length"> {{ diff.removed.join(', ') }}</span>
<span v-else class="text-gray-400"></span>
</td>
<td class="px-2 py-1 text-green-700">
<span v-if="diff.added.length">+ {{ diff.added.join(', ') }}</span>
<span v-else class="text-gray-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
@@ -45,7 +62,7 @@ const props = defineProps<{ entry: AuditLogEntry }>()
const { t } = useI18n()
// Extrait les entrees au shape { old, new } pour les updates.
// Extrait les entrees au shape { old, new } pour les updates scalaires.
const updateDiff = computed<Record<string, { old: unknown; new: unknown }>>(() => {
const out: Record<string, { old: unknown; new: unknown }> = {}
for (const [key, value] of Object.entries(props.entry.changes)) {
@@ -56,6 +73,22 @@ const updateDiff = computed<Record<string, { old: unknown; new: unknown }>>(() =
return out
})
// Extrait les entrees au shape { added, removed } pour les modifications
// de collections to-many (cf. AuditListener::captureCollectionChange).
const collectionDiff = computed<Record<string, { added: unknown[]; removed: unknown[] }>>(() => {
const out: Record<string, { added: unknown[]; removed: unknown[] }> = {}
for (const [key, value] of Object.entries(props.entry.changes)) {
if (value && typeof value === 'object' && 'added' in value && 'removed' in value) {
const diff = value as { added: unknown; removed: unknown }
out[key] = {
added: Array.isArray(diff.added) ? diff.added : [],
removed: Array.isArray(diff.removed) ? diff.removed : [],
}
}
}
return out
})
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '∅'
if (typeof value === 'boolean') return value ? 'oui' : 'non'

View File

@@ -51,6 +51,13 @@
<span class="mx-1">→</span>
<span class="text-green-700">{{ formatValue(diff.new) }}</span>
</div>
<!-- Modifications de collections to-many. -->
<div v-for="(diff, field) in collectionDiff(entry)" :key="`col-${field}`">
<span class="font-medium">{{ field }}</span> :
<span v-if="diff.removed.length" class="text-red-600">{{ diff.removed.join(', ') }}</span>
<span v-if="diff.removed.length && diff.added.length" class="mx-1"> </span>
<span v-if="diff.added.length" class="text-green-700">+{{ diff.added.join(', ') }}</span>
</div>
</div>
<div v-else class="mt-1 text-xs text-gray-600">
{{ snapshotSummary(entry) }}
@@ -104,29 +111,41 @@ const page = ref(1)
const totalItems = ref(0)
const loading = ref(false)
// Lazy loading : 10 items max par page visible cote UX. Le back fixe la
// limite a 30 (paginationItemsPerPage de AuditLogResource) ; on coupe a 10
// dans le composant pour ne pas saturer le flux visuel, et on laisse
// l'utilisateur demander plus via "Voir plus".
const INITIAL_LIMIT = 10
// Lazy loading : 10 items par page cote UX. On aligne la pagination backend
// (itemsPerPage=10 dans fetchEntityLogs) avec cette taille pour eviter de
// slicer cote client — sinon les items 11-30 de chaque page etaient ignores.
const PAGE_SIZE = 10
// Anti-race : un utilisateur qui change rapidement d'entite affichee (ouvre
// une ligne puis une autre dans le tableau admin) peut declencher deux fetchs
// dont le premier repond en retard et ecrase l'etat de la seconde timeline.
// On incremente un token a chaque fetch ; seule la derniere requete ecrit le
// resultat. loadMore() est aussi protege : une reponse tardive append sur
// une timeline dont l'entite a deja change serait visuellement confuse.
let requestToken = 0
const hasMore = computed(() => entries.value.length < totalItems.value)
async function loadPage(targetPage: number, append: boolean): Promise<void> {
if (!canView.value) return
const token = ++requestToken
loading.value = true
try {
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage)
const slice = (data.member ?? []).slice(0, append ? undefined : INITIAL_LIMIT)
entries.value = append ? [...entries.value, ...slice] : slice
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage, PAGE_SIZE)
if (token !== requestToken) return
const items = data.member ?? []
entries.value = append ? [...entries.value, ...items] : items
totalItems.value = data.totalItems ?? entries.value.length
page.value = targetPage
} catch {
if (token !== requestToken) return
// Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false.
entries.value = append ? entries.value : []
} finally {
loading.value = false
if (token === requestToken) {
loading.value = false
}
}
}
@@ -179,6 +198,22 @@ function updateDiff(entry: AuditLogEntry): Record<string, { old: unknown; new: u
return out
}
function collectionDiff(entry: AuditLogEntry): Record<string, { added: unknown[]; removed: unknown[] }> {
// Format to-many : { champ: { added: [ids], removed: [ids] } } produit
// par AuditListener::captureCollectionChange.
const out: Record<string, { added: unknown[]; removed: unknown[] }> = {}
for (const [key, value] of Object.entries(entry.changes)) {
if (value && typeof value === 'object' && 'added' in value && 'removed' in value) {
const diff = value as { added: unknown; removed: unknown }
out[key] = {
added: Array.isArray(diff.added) ? diff.added : [],
removed: Array.isArray(diff.removed) ? diff.removed : [],
}
}
}
return out
}
function snapshotSummary(entry: AuditLogEntry): string {
const keys = Object.keys(entry.changes)
if (keys.length === 0) return '—'

View File

@@ -1,5 +1,5 @@
import { ref } from 'vue'
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
import type { AuditLogEntityTypes, AuditLogEntry, AuditLogFilters } from '~/shared/types'
import type { HydraCollection } from '~/shared/utils/api'
import { onAuthSessionCleared } from '~/shared/stores/auth'
@@ -29,17 +29,25 @@ onAuthSessionCleared(resetAuditLog)
*
* @returns objet plat directement consommable par `useApi().get(url, query)`.
*/
function buildQuery(filters: AuditLogFilters | undefined): Record<string, string | number> {
const query: Record<string, string | number> = {}
function buildQuery(filters: AuditLogFilters | undefined): Record<string, string | number | string[]> {
const query: Record<string, string | number | string[]> = {}
if (!filters) return query
if (filters.entityType) query.entity_type = filters.entityType
// `entity_type` : chaine simple ou liste pour un filtre multi-selection.
// Cote PHP, la syntaxe `entity_type[]=X&entity_type[]=Y` est requise pour
// que $_GET['entity_type'] soit un tableau (sinon "last wins").
if (Array.isArray(filters.entityType)) {
if (filters.entityType.length > 0) query['entity_type[]'] = filters.entityType
} else if (filters.entityType) {
query.entity_type = filters.entityType
}
if (filters.entityId) query.entity_id = filters.entityId
if (filters.action) query.action = filters.action
if (filters.performedBy) query.performed_by = filters.performedBy
if (filters.performedAtAfter) query['performed_at[after]'] = filters.performedAtAfter
if (filters.performedAtBefore) query['performed_at[before]'] = filters.performedAtBefore
if (filters.page) query.page = filters.page
if (filters.itemsPerPage) query.itemsPerPage = filters.itemsPerPage
return query
}
@@ -84,18 +92,39 @@ export function useAuditLog() {
return api.get<AuditLogEntry>(`/audit-logs/${id}`, {}, { toast: false, headers: JSONLD_HEADERS })
}
/**
* Liste des valeurs distinctes de `entity_type` pour alimenter le filtre
* multi-selection. Alimente par un endpoint DBAL, aucune cache cote front
* (la liste peut evoluer a chaque nouvelle ecriture d'audit).
*/
async function fetchEntityTypes(): Promise<string[]> {
const data = await api.get<AuditLogEntityTypes>(
'/audit-log-entity-types',
{},
{ toast: false, headers: JSONLD_HEADERS },
)
return data.entityTypes ?? []
}
async function fetchEntityLogs(
entityType: string,
entityId: string | number,
page: number = 1,
itemsPerPage: number = 10,
): Promise<HydraCollection<AuditLogEntry>> {
// Volontairement via `fetchLogs` (sans cache) pour ne pas ecraser
// `lastCollection` — la timeline peut etre rendue simultanement a
// la page globale et doit rester independante.
//
// Le backend pagine a 30 par defaut (paginationItemsPerPage) ; on
// passe explicitement itemsPerPage ici pour que la taille de page
// soit alignee avec l'UX timeline (10 items + bouton "Voir plus").
// Sans ce param, le client slice a 10 et rate 20 entrees par page.
return fetchLogs({
entityType,
entityId: String(entityId),
page,
itemsPerPage,
})
}
@@ -104,6 +133,7 @@ export function useAuditLog() {
fetchLogs: fetchLogsCached,
fetchLogById,
fetchEntityLogs,
fetchEntityTypes,
resetAuditLog,
}
}

View File

@@ -35,11 +35,18 @@ export interface AuditLogEntry {
* `performed_at[before]`.
*/
export interface AuditLogFilters {
entityType?: string
/** Chaine pour un seul type, liste pour un filtre multi-selection. */
entityType?: string | string[]
entityId?: string
action?: string
performedBy?: string
performedAtAfter?: string
performedAtBefore?: string
page?: number
itemsPerPage?: number
}
export interface AuditLogEntityTypes {
id: string
entityTypes: string[]
}