Files
Coltura/frontend/shared/components/audit/AuditTimeline.vue
Matthieu b1255bb57a fix(review) : resout findings 3e passe review (HIGH frontend + MEDIUMs backend/frontend/E2E)
Backend :
- AuditLogWriter::stripSensitive rendu reellement recursif (matche doc).
- Tests GET /api/permissions/{id} non-admin pour chaque branche OR (gap Codex).
- Gardes non-regression UserRbacProcessor : PATCH /rbac sans clef sites ne
  doit ni auto-selectionner currentSite ni exiger sites.manage.

Frontend :
- useAuditLog : renomme export trompeur fetchLogs -> fetchLogsCached, le
  nom reflete desormais le comportement (cache pollue sinon).
- RoleDrawer / UserRbacDrawer : catch explicite + message d'erreur +
  bouton save disabled si le chargement des referentiels a echoue (evite
  un ecrasement silencieux des droits).
- AuditTimeline / AuditLogDetail : `oui`/`non` passent par common.yes/no.
- AuditTimeline : Intl.RelativeTimeFormat et toLocaleString suivent la
  locale i18n courante (plus de hardcode 'fr').

E2E :
- sidebar-visibility.spec : remplace waitForLoadState('networkidle')
  fragile par attente semantique sur accountDashboardLink (stable en CI).

Tests : 237/237 green, eslint clean, php-cs-fixer clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:31:03 +02:00

249 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!--
Garde permission : aucun rendu DOM ni appel API si l'utilisateur n'a
pas le droit. On wrappe le contenu dans un bloc v-if plutot qu'un div
vide pour eviter de polluer la layout quand le composant est embarque
dans une page qui rend deja sa propre structure.
-->
<div v-if="canView" 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>
<!-- 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) }}
</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, locale } = 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 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, 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 {
if (token === requestToken) {
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 via Intl.RelativeTimeFormat. On selectionne l'unite
// la plus grossiere possible (minutes < heures < jours < semaines). La
// locale suit dynamiquement celle de l'app pour qu'un switch de langue
// prenne effet sans nouveau mount (recomputed = cache par-locale).
const rtf = computed(() => new Intl.RelativeTimeFormat(locale.value, { 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)
const fmt = rtf.value
if (absSec < 60) return fmt.format(-Math.sign(diffSec) * Math.abs(diffSec), 'second')
if (absSec < 3600) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 60), 'minute')
if (absSec < 86400) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 3600), 'hour')
if (absSec < 604800) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 86400), 'day')
return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 604800), 'week')
}
function absoluteDate(iso: string): string {
// Meme logique : la locale de formatage suit celle de l'app.
return new Date(iso).toLocaleString(locale.value, {
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 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 '—'
if (keys.length <= 4) return keys.join(', ')
return `${keys.slice(0, 4).join(', ')}`
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '∅'
// Passe par i18n plutot qu'un hardcode FR : si une autre locale est
// ajoutee, le rendu s'adapte sans nouvelle passe sur ce composant.
if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
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>