## Résumé
Implémente le journal d'audit append-only couvrant les 5 tickets de `doc/audit-log.md` et embarque au passage plusieurs corrections périphériques (sidebar Admin/Mon compte, drawer RBAC, Swagger, schema_filter Doctrine) ainsi que l'initialisation de la suite e2e Playwright. Toutes les mutations Doctrine sur les entités portant `#[Auditable]` sont tracées dans une table PostgreSQL dédiée, exposée en lecture seule via API Platform et consultable par les admins dans une page dédiée.
## Ce qui change
### Audit log — cœur de la PR
**Backend**
- Migration : table `audit_log` (UUID v7 natif Postgres en PK, `jsonb changes`, 3 index pour tri chrono, par entité et par utilisateur).
- `AuditLogWriter` : service bas-niveau, écrit via une connexion DBAL dédiée `audit` (même DSN que `default`, service séparé) pour sortir de la transaction ORM en batch. Blacklist defense-in-depth `password`/`plainPassword`/`token`/`secret`.
- `RequestIdProvider` : UUID v4 généré au `kernel.request` principal, injecté dans chaque ligne d'audit de la requête.
- Attributs `#[Auditable]` / `#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` (accessibles par tous les modules).
- `AuditListener` : capture `onFlush` / écriture `postFlush` avec pattern swap-and-clear contre les flushes ré-entrants. Erreurs loguées, jamais propagées. Entité `User` annotée (password / plainPassword ignorés).
- API Platform read-only `/api/audit-logs` (permission RBAC `core.audit_log.view`) : `GET` collection paginée + `GET` item, pas de POST/PUT/PATCH/DELETE. Filtres `entity_type`, `entity_id`, `action`, `performed_by`, `performed_at[after]`/`[before]`.
- `DbalPaginator` implémentant `PaginatorInterface` : `hydra:view` généré automatiquement par API Platform, pas de construction manuelle.
- Ressource `AuditLogEntityTypesResource` + provider dédié pour peupler le filtre par type d'entité côté UI (réponse cachée, pas de requête à chaque ouverture du drawer).
- Permission `core.audit_log.view` déclarée dans `CoreModule::permissions()`.
- `audit_log` exclu du `schema_filter` Doctrine : plus de faux diff sur `make migration-diff`.
**Frontend**
- Page admin `/admin/audit-log` : tableau paginé, filtres locaux (état dans le composant, non persistés dans l'URL — conforme règle CLAUDE.md « Tableaux : pas de persistance URL »), drawer de détail (diff + timeline complète de l'entité), badges colorés par action.
- Composable partagé `useAuditLog` avec `resetAuditLog()` auto-enregistré sur `onAuthSessionCleared` (règle CLAUDE.md composables singletons).
- Composant réutilisable `<AuditTimeline :entity-type :entity-id>` : garde permission (pas d'appel API sans le droit), lazy loading (10 items + bouton « Voir plus »), dates relatives FR via `Intl.RelativeTimeFormat`, skeleton loader.
- Entrée sidebar « Journal d'audit » gated sur `core.audit_log.view` + clés i18n imbriquées dans `fr.json`.
### Fixes embarqués
- **Review fixes audit-log** (commits `37eafd2`, `1505e84`, `99c77eb`) : précision des timestamps, `ESCAPE` sur les `LIKE`, plafond pagination, diverses remarques du 1er tour de review.
- **Sidebar** (`701a480`, `e2fbf51`) : nouvelle section « Administration » + groupe « Mon compte », gate de section sur permissions, « Tableau de bord » déplacé dans « Mon compte ». Convention admin documentée.
- **Drawer RBAC utilisateurs** (`617ee31`, `5f5afcc`) : corrige l'affichage des sites et l'écrasement via merge-patch (garde anti-écrasement + spec `GET /users/{id}/rbac` documentée).
- **Swagger UI** (`6db955f`) : réactivé en ajoutant `symfony/twig-bundle` aux deps (régression depuis l'arrivée d'API Platform 4.2).
- **`phpunit.dist.xml`** : `<env APP_ENV=dev>` forçait la suite à tourner sous `framework.test=false` (→ `test.service_container` introuvable) ; `JWT_PASSPHRASE` ne matchait pas les clés de dev. Corrigés pour débloquer la suite.
### E2E Playwright (nouveau, commit `4603ab2`)
- `playwright.config.ts` + structure `frontend/tests/e2e/` (personas, helpers `loginAs`, page objects `LoginPage` + `SidebarComponent`).
- Specs : `auth/login.spec.ts` + `permissions/sidebar-visibility.spec.ts` (vérifie la visibilité de la sidebar par rôle RBAC).
- Commande `SeedE2ECommand` pour préparer un jeu de données déterministe côté backend.
- `make e2e` ajouté au Makefile.
## Décisions techniques
- **UUID v7 natif Postgres** (16 octets vs 36 en varchar) : index `performed_at` ~40 % plus petit sur une table append-only à croissance infinie.
- **`entity_type` format `module.Entity`** (ex: `core.User`) : évite les collisions si deux modules ont des entités de même nom.
- **`performed_by` dénormalisé** (string, pas FK) : le nom persiste même après suppression de l'utilisateur.
- **Connexion DBAL dédiée `audit`** : évite l'entanglement transactionnel entre audit et ORM en batch.
- **`ManyToMany` non audité** : limitation connue (`getEntityChangeSet()` ne couvre pas les collections) ; extension future via `getScheduledCollectionUpdates()` si besoin.
- **Filtres locaux non persistés dans l'URL** : choix assumé (cf. CLAUDE.md) pour éviter le couplage table ↔ routeur.
## Test plan
- [x] `make test` : 218 tests passent (writer unitaires + listener intégration + API fonctionnels + UserRbacProcessor).
- [x] `npm run lint` + `npm run test` + `npm run build` (frontend).
- [x] Migration appliquée sur dev + test, `audit_log` ignoré par `schema_filter`.
- [x] Permissions synchronisées (`app:sync-permissions`).
- [x] Swagger `/api/docs` accessible de nouveau.
- [ ] Playwright : `make e2e` vert en local (login + sidebar-visibility).
- [ ] Vérifier en local : création/modif/suppression d'un user apparaît dans `/admin/audit-log`.
- [ ] Vérifier : user sans `core.audit_log.view` → 403 sur l'endpoint + item absent de la sidebar.
- [ ] Vérifier : expansion d'une ligne affiche la timeline de l'entité avec dates relatives FR.
- [ ] Vérifier : drawer RBAC utilisateur n'écrase plus la liste des sites au `PATCH`.
## Points d'attention pour le review
- `AuditListener` : pattern swap-and-clear sur `postFlush` — relire la gestion des flushes ré-entrants.
- `DbalPaginator` : vérifier que l'absence d'`Iterator` custom ne casse pas la normalisation API Platform sur collections vides.
- `UserRbacProcessor` : logique merge-patch + garde anti-écrasement des sites (régression corrigée dans `617ee31`).
- Playwright : nouvelle dépendance de dev, s'assurer que `make e2e` ne fait pas partie du pipeline CI par défaut (à brancher explicitement).
Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #9
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
423 lines
16 KiB
Vue
423 lines
16 KiB
Vue
<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">
|
|
<!-- 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="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="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="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="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="mb-1 block text-xs font-medium text-gray-600">
|
|
{{ t('audit.filters.entity_type') }}
|
|
</label>
|
|
<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="mb-1 block text-xs font-medium text-gray-600">
|
|
{{ t('audit.filters.user') }}
|
|
</label>
|
|
<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="mb-1 block text-xs font-medium text-gray-600">
|
|
{{ t('audit.filters.action') }}
|
|
</label>
|
|
<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">
|
|
<MalioButton
|
|
variant="tertiary"
|
|
:label="t('audit.filters.reset')"
|
|
button-class="text-xs"
|
|
@click="resetFilters"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Tableau -->
|
|
<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="text-xs"
|
|
:title="item.entityType as string"
|
|
>{{ formatEntityType(item.entityType as string) }}</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>
|
|
|
|
<!-- 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"
|
|
:title="selectedEntry.entityType"
|
|
>
|
|
{{ formatEntityType(selectedEntry.entityType) }} #{{ selectedEntry.entityId }}
|
|
</h3>
|
|
<AuditTimeline
|
|
:entity-type="selectedEntry.entityType"
|
|
:entity-id="selectedEntry.entityId"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</MalioDrawer>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
|
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
|
|
|
const { t, te } = useI18n()
|
|
const { can } = usePermissions()
|
|
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
|
|
|
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
|
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
|
// traduction n'existe, on retombe sur l'identifiant brut pour rester debug-friendly.
|
|
function formatEntityType(type: string): string {
|
|
const key = `audit.entity.${type.toLowerCase().replace(/\./g, '_')}`
|
|
return te(key) ? t(key) : type
|
|
}
|
|
|
|
// Protection cote UI : le middleware `modules.global.ts` filtre deja les
|
|
// routes desactivees, mais si quelqu'un atterit ici sans la permission on
|
|
// 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') })
|
|
|
|
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
|
// CLAUDE.md "Tableau : pas de persistance URL").
|
|
const filters = reactive<AuditLogFilters>({
|
|
performedAtAfter: undefined,
|
|
performedAtBefore: undefined,
|
|
entityType: undefined,
|
|
performedBy: undefined,
|
|
action: undefined,
|
|
page: 1,
|
|
itemsPerPage: 10,
|
|
})
|
|
|
|
// 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[]>([])
|
|
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
|
|
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
|
|
const entityTypeOptions = computed(() =>
|
|
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
|
)
|
|
|
|
// 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 loading = ref(false)
|
|
|
|
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
|
|
? `${formatEntityType(selectedEntry.value.entityType)} #${selectedEntry.value.entityId}`
|
|
: t('audit.detail_title'),
|
|
)
|
|
|
|
const isFiltered = computed(() =>
|
|
Boolean(filters.performedAtAfter || filters.performedAtBefore
|
|
|| (Array.isArray(filters.entityType) ? filters.entityType.length : filters.entityType)
|
|
|| filters.performedBy || filters.action),
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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
|
|
|
|
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
|
|
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 fetchLogsCached({
|
|
...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,
|
|
})
|
|
// 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
|
|
} catch {
|
|
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
|
|
// laisser l'utilisateur croire que les donnees affichees sont a jour.
|
|
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
|
|
if (token === requestToken) {
|
|
entries.value = []
|
|
totalItems.value = 0
|
|
}
|
|
} finally {
|
|
if (token === requestToken) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
|
|
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
|
|
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
|
|
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().
|
|
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 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 onPageChange(value: number): void {
|
|
filters.page = value
|
|
loadEntries()
|
|
}
|
|
|
|
function onPerPageChange(value: number): void {
|
|
filters.itemsPerPage = value
|
|
filters.page = 1
|
|
loadEntries()
|
|
}
|
|
|
|
// 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()
|
|
})
|
|
|
|
// 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],
|
|
() => {
|
|
if (watchersSuspended) return
|
|
filters.page = 1
|
|
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>
|