fix(audit-log) : applique fixes code review PR #9
Resout les 5 findings de la review automatique + couverture ManyToMany annoncee dans CLAUDE.md : - AuditListener : resolution de la classe via ClassMetadata plutot que `$entity::class` direct (defense proxy Doctrine : sous ORM 2 les lazies sont des `Proxies\__CG__\...`). Test de regression via getReference(). - AuditListener : capture des modifications de collections to-many (OneToMany / ManyToMany) via getScheduledCollectionUpdates / getScheduledCollectionDeletions. Les diffs sont mergees dans le changeset existant ou creent une entree "update" dediee. - AuditLogResource + Provider : filtre multi-valeurs `entity_type[]=X&entity_type[]=Y` (IN clause DBAL via ArrayParameterType::STRING), endpoint `/audit-log-entity-types` pour alimenter le MalioSelectCheckbox cote front. - audit-log.vue : refonte complete. Passage a `MalioDataTable`, composants `Malio*` (MalioInputText, MalioSelectCheckbox, MalioButton), suppression complete de la persistance URL (`readQuery` / `syncQuery` / `route.query`). `datetime-local` conserve avec TODO pointant l'exception CLAUDE.md. - AuditTimeline : fix du saut d'items 11-30. `PAGE_SIZE = 10` aligne avec un `itemsPerPage=10` passe au backend. Token anti-race pour ignorer les reponses tardives quand l'entite affichee change. - AuditLogDetail : affichage des diffs de collections to-many (+ / -) dans le tableau field/old/new existant. - logout.vue : ajout du `resetAuditLog()` au logout pour eviter qu'un user suivant (meme onglet) voie l'etat audit de l'ancien. - Permission / Role / Site : marquage `#[Auditable]`. - Version bump 0.1.32 → 0.1.34. Tests : 228 / 228 (221 assertions → 851, dont regressions proxy + M2M). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,260 +8,271 @@
|
||||
|
||||
<!-- 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">
|
||||
<!-- 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="block text-xs font-medium text-gray-600">
|
||||
<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="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
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="block text-xs font-medium text-gray-600">
|
||||
<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="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
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="block text-xs font-medium text-gray-600">
|
||||
<label class="mb-1 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 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="block text-xs font-medium text-gray-600">
|
||||
<label class="mb-1 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"
|
||||
>
|
||||
<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="block text-xs font-medium text-gray-600">
|
||||
<label class="mb-1 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>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="text-xs"
|
||||
@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>
|
||||
<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="font-mono text-xs">{{ item.entityType }}</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>
|
||||
|
||||
<!-- Pagination via hydra (view.next / view.previous) -->
|
||||
<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>
|
||||
<!-- 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">
|
||||
{{ selectedEntry.entityType }} #{{ selectedEntry.entityId }}
|
||||
</h3>
|
||||
<AuditTimeline
|
||||
:entity-type="selectedEntry.entityType"
|
||||
:entity-id="selectedEntry.entityId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, 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()
|
||||
const { fetchLogs, fetchEntityTypes } = 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.
|
||||
// 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') })
|
||||
|
||||
const allActions = ['create', 'update', 'delete'] as const
|
||||
type ActionKind = typeof allActions[number]
|
||||
|
||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
||||
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,
|
||||
performedAtAfter: undefined,
|
||||
performedAtBefore: undefined,
|
||||
entityType: undefined,
|
||||
performedBy: undefined,
|
||||
action: undefined,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
})
|
||||
|
||||
// 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] : [])
|
||||
// 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[]>([])
|
||||
const entityTypeOptions = computed(() =>
|
||||
entityTypes.value.map(t => ({ value: t, label: t })),
|
||||
)
|
||||
|
||||
// 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 hasPrevious = ref(false)
|
||||
const hasNext = ref(false)
|
||||
const loading = ref(false)
|
||||
const expandedId = ref<string | null>(null)
|
||||
|
||||
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
|
||||
? `${selectedEntry.value.entityType} #${selectedEntry.value.entityId}`
|
||||
: t('audit.detail_title'),
|
||||
)
|
||||
|
||||
const isFiltered = computed(() =>
|
||||
Boolean(filters.performedAtAfter || filters.performedAtBefore || filters.entityType
|
||||
Boolean(filters.performedAtAfter || filters.performedAtBefore
|
||||
|| (Array.isArray(filters.entityType) ? filters.entityType.length : filters.entityType)
|
||||
|| filters.performedBy || filters.action),
|
||||
)
|
||||
|
||||
function readQuery(key: string): string | undefined {
|
||||
const v = route.query[key]
|
||||
return typeof v === 'string' && v !== '' ? v : undefined
|
||||
}
|
||||
// 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
|
||||
|
||||
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()
|
||||
}
|
||||
// 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
|
||||
|
||||
function resetFilters(): void {
|
||||
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
|
||||
selectedActions.value = []
|
||||
syncQuery()
|
||||
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 fetchLogs({
|
||||
@@ -270,16 +281,30 @@ async function loadEntries(): Promise<void> {
|
||||
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
|
||||
const view = data.view
|
||||
hasPrevious.value = Boolean(view?.previous)
|
||||
hasNext.value = Boolean(view?.next)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (token === requestToken) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce utilitaire pour le champ texte performedBy : evite un refetch a
|
||||
// chaque frappe (reseau + SQL) et laisse l'utilisateur finir sa saisie.
|
||||
function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): T {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
return ((...args: Parameters<T>) => {
|
||||
if (null !== timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => fn(...args), delay)
|
||||
}) as T
|
||||
}
|
||||
|
||||
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().
|
||||
@@ -309,53 +334,72 @@ function summarize(entry: AuditLogEntry): string {
|
||||
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
|
||||
}
|
||||
|
||||
function toggleExpand(id: string): void {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
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 goPrevious(): void {
|
||||
if (!hasPrevious.value || !filters.page) return
|
||||
filters.page = Math.max(1, filters.page - 1)
|
||||
syncQuery()
|
||||
function onPageChange(value: number): void {
|
||||
filters.page = value
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function goNext(): void {
|
||||
if (!hasNext.value) return
|
||||
filters.page = (filters.page ?? 1) + 1
|
||||
syncQuery()
|
||||
function onPerPageChange(value: number): void {
|
||||
filters.itemsPerPage = value
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
// 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 })
|
||||
}
|
||||
// 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()
|
||||
})
|
||||
|
||||
// Synchronisation reactive : tout changement de filtre declenche un fetch
|
||||
// + reset de la pagination a la page 1. La navigation page (prev/next) ne
|
||||
// passe PAS par un watcher : elle appelle `loadEntries()` directement dans
|
||||
// `goPrevious`/`goNext`. Cette separation evite un double-fetch concurrent
|
||||
// quand une filtre reset la page a 1 (sinon le watch de `filters.page`
|
||||
// serait declenche une seconde fois en parallele).
|
||||
// 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, filters.entityType, filters.performedBy, filters.action],
|
||||
() => [filters.performedAtAfter, filters.performedAtBefore],
|
||||
() => {
|
||||
if (watchersSuspended) return
|
||||
filters.page = 1
|
||||
syncQuery()
|
||||
loadEntries()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
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>
|
||||
|
||||
@@ -11,6 +11,7 @@ const auth = useAuthStore()
|
||||
const { resetSidebar } = useSidebar()
|
||||
const { resetModules } = useModules()
|
||||
const { resetCurrentSite } = useCurrentSite()
|
||||
const { resetAuditLog } = useAuditLog()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -18,13 +19,14 @@ onMounted(async () => {
|
||||
} finally {
|
||||
// Les resets sont garantis meme si auth.logout() rejette : eviter
|
||||
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||
// l'ancien. Les trois fonctions reset sont synchrones et ne
|
||||
// l'ancien. Toutes les fonctions reset sont synchrones et ne
|
||||
// peuvent pas throw (juste des assignations reactives).
|
||||
// navigateTo est dans le finally pour garantir la redirection
|
||||
// meme si auth.logout() lance une exception (ex: reseau coupé).
|
||||
resetSidebar()
|
||||
resetModules()
|
||||
resetCurrentSite()
|
||||
resetAuditLog()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user