feat : add audit log (table, writer, listener, API, admin UI, timeline)
Implemente le journal d'audit append-only sur toutes les mutations Doctrine des entites portant #[Auditable]. Couvre les 5 tickets de doc/audit-log.md : 1. Table PG audit_log (uuid PK, jsonb changes, index entity/time/performer) + AuditLogWriter (DBAL connexion dediee audit, blacklist defense-in-depth sur password/plainPassword/token/secret) + RequestIdProvider (UUID v4 par requete HTTP principale). 2. Attributs Auditable / AuditIgnore dans Shared/Domain/Attribute/ + AuditListener (onFlush capture + postFlush ecriture hors transaction ORM, pattern swap-and-clear, erreurs loguees jamais propagees). User annote. 3. API Platform read-only /api/audit-logs (permission core.audit_log.view) avec filtres entity_type / entity_id / action / performed_by / plage performed_at + DbalPaginator implementant PaginatorInterface (hydra:view genere automatiquement). 4. Page admin /admin/audit-log : tableau pagine, filtres persistes en query params, row expandable (diff + timeline de l'entite), entree sidebar avec permission. Composable useAuditLog avec resetAuditLog() auto-enregistre sur onAuthSessionCleared. 5. Composant AuditTimeline reutilisable : garde permission, lazy loading, dates relatives FR, skeleton loader. Fix connexe : phpunit.dist.xml forcait APP_ENV=dev via <env> ce qui cablait framework.test=false et rendait test.service_container indisponible ; le JWT_PASSPHRASE ne matchait pas non plus les cles dev. Corrige en meme temps pour debloquer la suite de tests.
This commit is contained in:
356
frontend/modules/core/pages/admin/audit-log.vue
Normal file
356
frontend/modules/core/pages/admin/audit-log.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<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 -->
|
||||
<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['hydra:member'] ?? []
|
||||
totalItems.value = data['hydra:totalItems'] ?? 0
|
||||
const view = data['hydra:view']
|
||||
hasPrevious.value = Boolean(view?.['hydra:previous'])
|
||||
hasNext.value = Boolean(view?.['hydra: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()
|
||||
}
|
||||
|
||||
function goNext(): void {
|
||||
if (!hasNext.value) return
|
||||
filters.page = (filters.page ?? 1) + 1
|
||||
syncQuery()
|
||||
}
|
||||
|
||||
// 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 (sauf si seul `page` a change).
|
||||
watch(
|
||||
() => [filters.performedAtAfter, filters.performedAtBefore, filters.entityType, filters.performedBy, filters.action],
|
||||
() => {
|
||||
filters.page = 1
|
||||
syncQuery()
|
||||
loadEntries()
|
||||
},
|
||||
)
|
||||
watch(() => filters.page, () => { loadEntries() })
|
||||
|
||||
onMounted(() => {
|
||||
loadEntries()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user