Files
Starseed/frontend/shared/components/audit/AuditTimeline.vue
matthieu de39fe6a3e 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.
2026-04-20 20:51:10 +02:00

205 lines
7.7 KiB
Vue

<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>