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:
@@ -26,7 +26,8 @@
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
"sites": "Sites"
|
||||
"sites": "Sites",
|
||||
"audit_log": "Journal d'audit"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -66,6 +67,35 @@
|
||||
"switchSuccess": "Site courant changé"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"action": {
|
||||
"create": "Création",
|
||||
"update": "Modification",
|
||||
"delete": "Suppression"
|
||||
},
|
||||
"entity": {
|
||||
"user": "Utilisateur"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
"timeline": {
|
||||
"empty": "Aucun historique",
|
||||
"load_more": "Voir plus"
|
||||
},
|
||||
"filters": {
|
||||
"reset": "Réinitialiser",
|
||||
"date_from": "Du",
|
||||
"date_to": "Au",
|
||||
"entity_type": "Type d'entité",
|
||||
"user": "Utilisateur",
|
||||
"action": "Action"
|
||||
},
|
||||
"detail": {
|
||||
"field": "Champ",
|
||||
"old_value": "Ancienne valeur",
|
||||
"new_value": "Nouvelle valeur"
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"auth": {
|
||||
"logout": "Deconnexion reussie"
|
||||
@@ -132,6 +162,21 @@
|
||||
"updated": "Permissions mises à jour avec succès"
|
||||
}
|
||||
},
|
||||
"auditLog": {
|
||||
"title": "Journal d'audit",
|
||||
"table": {
|
||||
"performedAt": "Date",
|
||||
"performedBy": "Utilisateur",
|
||||
"entityType": "Entité",
|
||||
"entityId": "ID",
|
||||
"action": "Action",
|
||||
"summary": "Résumé"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"title": "Gestion des sites",
|
||||
"newSite": "Nouveau site",
|
||||
|
||||
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>
|
||||
65
frontend/shared/components/audit/AuditLogDetail.vue
Normal file
65
frontend/shared/components/audit/AuditLogDetail.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<!--
|
||||
Vue de detail d'une ligne d'audit : tableau field/old/new pour une
|
||||
update, sinon snapshot complet sous forme de liste { cle: valeur }.
|
||||
-->
|
||||
<div class="text-sm">
|
||||
<p class="text-xs text-gray-500 mb-2">
|
||||
<span v-if="entry.ipAddress">IP: {{ entry.ipAddress }}</span>
|
||||
<span v-if="entry.requestId" class="ml-3">Req: {{ entry.requestId }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="entry.action === 'update'">
|
||||
<table class="min-w-full border border-gray-200 text-xs">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.field') }}</th>
|
||||
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.old_value') }}</th>
|
||||
<th class="px-2 py-1 text-left font-medium">{{ t('audit.detail.new_value') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(diff, field) in updateDiff" :key="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">{{ formatValue(diff.old) }}</td>
|
||||
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<div v-for="(value, key) in entry.changes" :key="key" class="flex gap-2">
|
||||
<span class="font-mono text-xs text-gray-600">{{ key }}:</span>
|
||||
<span class="text-xs">{{ formatValue(value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AuditLogEntry } from '~/shared/types'
|
||||
|
||||
const props = defineProps<{ entry: AuditLogEntry }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Extrait les entrees au shape { old, new } pour les updates.
|
||||
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)) {
|
||||
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
|
||||
out[key] = value as { old: unknown; new: unknown }
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '∅'
|
||||
if (typeof value === 'boolean') return value ? 'oui' : 'non'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
</script>
|
||||
204
frontend/shared/components/audit/AuditTimeline.vue
Normal file
204
frontend/shared/components/audit/AuditTimeline.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<!-- Garde permission : aucun rendu ni appel API si l'utilisateur n'a pas le droit. -->
|
||||
<div v-if="!canView" />
|
||||
|
||||
<div v-else class="audit-timeline">
|
||||
<!-- Skeleton loader initial -->
|
||||
<ul v-if="loading && entries.length === 0" class="space-y-3">
|
||||
<li v-for="i in 3" :key="i" class="flex gap-3">
|
||||
<div class="h-3 w-3 rounded-full bg-gray-200 animate-pulse mt-1.5" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3 w-1/3 rounded bg-gray-200 animate-pulse" />
|
||||
<div class="h-2 w-2/3 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="!loading && entries.length === 0"
|
||||
class="text-sm text-gray-500 italic"
|
||||
>
|
||||
{{ t('audit.timeline.empty') }}
|
||||
</p>
|
||||
|
||||
<ul v-else class="relative border-l-2 border-gray-200 pl-6 space-y-5">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="relative"
|
||||
>
|
||||
<!-- Dot sur la barre verticale. Couleur selon action. -->
|
||||
<span
|
||||
class="absolute -left-[31px] top-1 h-3 w-3 rounded-full ring-2 ring-white"
|
||||
:class="dotClass(entry.action)"
|
||||
/>
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm">
|
||||
<span class="font-medium">{{ entry.performedBy }}</span>
|
||||
<span class="text-gray-500"> — {{ t(`audit.action.${entry.action}`) }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Update : diff field-by-field. Create/Delete : liste des champs. -->
|
||||
<div v-if="entry.action === 'update'" class="mt-1 text-xs text-gray-600 space-y-0.5">
|
||||
<div v-for="(diff, field) in updateDiff(entry)" :key="field">
|
||||
<span class="font-medium">{{ field }}</span> :
|
||||
<span class="line-through text-red-600">{{ formatValue(diff.old) }}</span>
|
||||
<span class="mx-1">→</span>
|
||||
<span class="text-green-700">{{ formatValue(diff.new) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-1 text-xs text-gray-600">
|
||||
{{ snapshotSummary(entry) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date relative FR + tooltip absolu -->
|
||||
<time
|
||||
:title="absoluteDate(entry.performedAt)"
|
||||
class="shrink-0 text-xs text-gray-500"
|
||||
>
|
||||
{{ relativeDate(entry.performedAt) }}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Lazy loading : bouton "Voir plus" si plus de pages. -->
|
||||
<div v-if="hasMore" class="mt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-60"
|
||||
:disabled="loading"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ loading ? t('common.loading') : t('audit.timeline.load_more') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, toRefs, watch } from 'vue'
|
||||
import type { AuditLogEntry } from '~/shared/types'
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: string
|
||||
entityId: string | number
|
||||
}>()
|
||||
|
||||
const { entityType, entityId } = toRefs(props)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { fetchEntityLogs } = useAuditLog()
|
||||
|
||||
const canView = computed(() => can('core.audit_log.view'))
|
||||
|
||||
const entries = ref<AuditLogEntry[]>([])
|
||||
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
|
||||
|
||||
const hasMore = computed(() => entries.value.length < totalItems.value)
|
||||
|
||||
async function loadPage(targetPage: number, append: boolean): Promise<void> {
|
||||
if (!canView.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage)
|
||||
const slice = (data['hydra:member'] ?? []).slice(0, append ? undefined : INITIAL_LIMIT)
|
||||
entries.value = append ? [...entries.value, ...slice] : slice
|
||||
totalItems.value = data['hydra:totalItems'] ?? entries.value.length
|
||||
page.value = targetPage
|
||||
} catch {
|
||||
// Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false.
|
||||
entries.value = append ? entries.value : []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
await loadPage(page.value + 1, true)
|
||||
}
|
||||
|
||||
function dotClass(action: string): string {
|
||||
switch (action) {
|
||||
case 'create': return 'bg-green-500'
|
||||
case 'update': return 'bg-yellow-500'
|
||||
case 'delete': return 'bg-red-500'
|
||||
default: return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
// Relativise une date en francais via Intl.RelativeTimeFormat. On selectionne
|
||||
// l'unite la plus grossiere possible (minutes < heures < jours < semaines).
|
||||
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
|
||||
|
||||
function relativeDate(iso: string): string {
|
||||
const diffMs = Date.now() - new Date(iso).getTime()
|
||||
const diffSec = Math.round(diffMs / 1000)
|
||||
const absSec = Math.abs(diffSec)
|
||||
|
||||
if (absSec < 60) return rtf.format(-Math.sign(diffSec) * Math.abs(diffSec), 'second')
|
||||
if (absSec < 3600) return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 60), 'minute')
|
||||
if (absSec < 86400) return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 3600), 'hour')
|
||||
if (absSec < 604800) return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 86400), 'day')
|
||||
return rtf.format(-Math.sign(diffSec) * Math.round(absSec / 604800), 'week')
|
||||
}
|
||||
|
||||
function absoluteDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function updateDiff(entry: AuditLogEntry): Record<string, { old: unknown; new: unknown }> {
|
||||
// Format attendu: { champ: { old, new } }. On filtre defensivement les
|
||||
// valeurs qui ne correspondent pas a ce shape (pas d'erreur runtime).
|
||||
const out: Record<string, { old: unknown; new: unknown }> = {}
|
||||
for (const [key, value] of Object.entries(entry.changes)) {
|
||||
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
|
||||
const diff = value as { old: unknown; new: unknown }
|
||||
out[key] = diff
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function snapshotSummary(entry: AuditLogEntry): string {
|
||||
const keys = Object.keys(entry.changes)
|
||||
if (keys.length === 0) return '—'
|
||||
if (keys.length <= 4) return keys.join(', ')
|
||||
return `${keys.slice(0, 4).join(', ')}…`
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '∅'
|
||||
if (typeof value === 'boolean') return value ? 'oui' : 'non'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// Reload si l'entite affichee change.
|
||||
watch([entityType, entityId], () => {
|
||||
entries.value = []
|
||||
page.value = 1
|
||||
totalItems.value = 0
|
||||
loadPage(1, false)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadPage(1, false)
|
||||
})
|
||||
</script>
|
||||
89
frontend/shared/composables/useAuditLog.ts
Normal file
89
frontend/shared/composables/useAuditLog.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ref } from 'vue'
|
||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* Cache module-level : evite un double-fetch si la page et le composant
|
||||
* Timeline demandent la meme page simultanement. Volontairement minimaliste :
|
||||
* on ne cache que le dernier resultat, pas un LRU par filtre — un CRM interne
|
||||
* n'en a pas besoin et le cache complexe complique le reset.
|
||||
*
|
||||
* Un logout / 401 doit purger ce cache : on s'enregistre au callback
|
||||
* `onAuthSessionCleared` expose par auth.ts.
|
||||
*/
|
||||
const lastCollection = ref<HydraCollection<AuditLogEntry> | null>(null)
|
||||
|
||||
function resetAuditLog(): void {
|
||||
lastCollection.value = null
|
||||
}
|
||||
|
||||
// Auto-enregistrement singleton : si la session est invalidee (401,
|
||||
// logout) le cache est purge automatiquement, evitant qu'un autre user
|
||||
// connecte ensuite ne voit des donnees residuelles.
|
||||
onAuthSessionCleared(resetAuditLog)
|
||||
|
||||
/**
|
||||
* Traduit le modele front (camelCase) en query params API Platform
|
||||
* (snake_case, avec la syntaxe performed_at[after] / [before]).
|
||||
*
|
||||
* @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> = {}
|
||||
if (!filters) return query
|
||||
|
||||
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
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable partage entre la page globale d'audit (admin) et le composant
|
||||
* Timeline. Expose des methodes de lecture + une fonction `resetAuditLog()`
|
||||
* pour purger le cache (conforme a la regle CLAUDE.md sur les composables
|
||||
* singletons, cf. `useSidebar.resetSidebar`).
|
||||
*/
|
||||
export function useAuditLog() {
|
||||
const api = useApi()
|
||||
|
||||
async function fetchLogs(filters?: AuditLogFilters): Promise<HydraCollection<AuditLogEntry>> {
|
||||
const data = await api.get<HydraCollection<AuditLogEntry>>(
|
||||
'/audit-logs',
|
||||
buildQuery(filters),
|
||||
{ toast: false },
|
||||
)
|
||||
lastCollection.value = data
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchLogById(id: string): Promise<AuditLogEntry> {
|
||||
return api.get<AuditLogEntry>(`/audit-logs/${id}`, {}, { toast: false })
|
||||
}
|
||||
|
||||
async function fetchEntityLogs(
|
||||
entityType: string,
|
||||
entityId: string | number,
|
||||
page: number = 1,
|
||||
): Promise<HydraCollection<AuditLogEntry>> {
|
||||
return fetchLogs({
|
||||
entityType,
|
||||
entityId: String(entityId),
|
||||
page,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
lastCollection,
|
||||
fetchLogs,
|
||||
fetchLogById,
|
||||
fetchEntityLogs,
|
||||
resetAuditLog,
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,37 @@ export interface SidebarSection {
|
||||
icon: string
|
||||
items: SidebarItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Entree d'audit telle qu'elle est renvoyee par GET /api/audit-logs.
|
||||
*
|
||||
* `changes` est un payload libre dont le format depend de `action` :
|
||||
* - `create` / `delete` : snapshot complet { champ: valeur } ;
|
||||
* - `update` : diff { champ: { old, new } }.
|
||||
*/
|
||||
export interface AuditLogEntry {
|
||||
id: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
action: 'create' | 'update' | 'delete'
|
||||
changes: Record<string, unknown>
|
||||
performedBy: string
|
||||
performedAt: string
|
||||
ipAddress: string | null
|
||||
requestId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtres combinables en query params (AND) pour GET /api/audit-logs.
|
||||
* Les bornes de date utilisent la syntaxe API Platform `performed_at[after]` /
|
||||
* `performed_at[before]`.
|
||||
*/
|
||||
export interface AuditLogFilters {
|
||||
entityType?: string
|
||||
entityId?: string
|
||||
action?: string
|
||||
performedBy?: string
|
||||
performedAtAfter?: string
|
||||
performedAtBefore?: string
|
||||
page?: number
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
export interface HydraView {
|
||||
'@id'?: string
|
||||
'@type'?: string
|
||||
'hydra:first'?: string
|
||||
'hydra:last'?: string
|
||||
'hydra:next'?: string
|
||||
'hydra:previous'?: string
|
||||
}
|
||||
|
||||
export interface HydraCollection<T> {
|
||||
'hydra:member': T[]
|
||||
'hydra:totalItems': number
|
||||
'hydra:view'?: HydraView
|
||||
}
|
||||
|
||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||
|
||||
Reference in New Issue
Block a user