feat(audit) : contexte forensique dans le journal d'activité (IP, appareil, device id) (#33)
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
## Contexte Certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »), y compris depuis des smartphones. Le journal d'activité ne stockait que le `username` → impossible de distinguer les intervenants. Cette PR ajoute un **contexte forensique automatique** à chaque entrée du journal. ## Ce qui est ajouté (capté automatiquement, sans friction utilisateur) - **Adresse IP** de la requête - **User-Agent brut** (borné à 1024 caractères) - **Libellé appareil lisible** dérivé du User-Agent : `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`) - **Identifiant d'appareil persistant** envoyé par le front (header `X-Device-Id`, stocké en `localStorage`, borné à 64 car.) — distingue les **appareils** derrière un compte partagé ## Implémentation - `UserAgentParser` (service maison, sans dépendance) — détection ordonnée OS/navigateur, testée - 4 colonnes **nullable** sur `audit_logs` + migration réversible (pas de backfill, rétro-compatible) - Capture **centralisée** dans `AuditLogger::log()` via `RequestStack` — aucun processor modifié - Champs exposés dans l'API lecture (`AuditLogProvider` + DTO TS aligné) via `AuditLogReadRepositoryInterface` (suit le pattern existant des autres read-repos) - Front : `useDeviceId` + injection du header `X-Device-Id` dans `useApi` (sur toutes les requêtes, SSR-safe) - `framework.trusted_proxies` documenté (commenté) pour une IP correcte derrière un reverse proxy - Docs : `doc/audit-logging.md` + `CLAUDE.md` ## Hors périmètre (étapes suivantes) - **Écran du journal (`audit-logs.vue`) non modifié** — l'affichage des nouvelles colonnes fera l'objet d'une refonte séparée. Les données sont prêtes côté API. - La doc in-app (`documentation-content.ts`) n'est pas touchée : le journal est un outil caché `ROLE_SUPER_ADMIN` sans article existant ni niveau de doc super-admin. ## À noter pour le déploiement - L'IP n'est fiable derrière un reverse proxy qu'une fois `framework.trusted_proxies` activé (livré commenté). ## Tests `OK (249 tests, 533 assertions)` — sortie PHPUnit propre (aucune notice). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #33 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #33.
This commit is contained in:
@@ -80,6 +80,14 @@ export const useApi = (): ApiClient => {
|
||||
baseURL,
|
||||
retry: 0,
|
||||
credentials: 'include',
|
||||
onRequest({ options }) {
|
||||
const deviceId = useDeviceId()
|
||||
if (deviceId) {
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||
headers.set('X-Device-Id', deviceId)
|
||||
options.headers = headers
|
||||
}
|
||||
},
|
||||
onResponse({ options, response }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (apiOptions?.toast === false) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { AuditLog } from '~/services/dto/audit-log'
|
||||
import { fetchAuditLogs, type AuditLogFilters } from '~/services/audit-logs'
|
||||
|
||||
type Range = { start: string, end: string } | null
|
||||
|
||||
export const useAuditLogsList = () => {
|
||||
const items = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
const loading = ref(false)
|
||||
const filterOpen = ref(false)
|
||||
|
||||
// Applied filters (drive the fetch)
|
||||
const appliedEmployee = ref('')
|
||||
const appliedRange = ref<Range>(null)
|
||||
const appliedEntityTypes = ref<string[]>([])
|
||||
const appliedActions = ref<string[]>([])
|
||||
const appliedUsername = ref('')
|
||||
const appliedIp = ref('')
|
||||
const appliedDevice = ref('')
|
||||
|
||||
// Draft filters (edited inside the drawer)
|
||||
const draftEmployee = ref('')
|
||||
const draftRange = ref<Range>(null)
|
||||
const draftEntityTypes = ref<string[]>([])
|
||||
const draftActions = ref<string[]>([])
|
||||
const draftUsername = ref('')
|
||||
const draftIp = ref('')
|
||||
const draftDevice = ref('')
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let n = 0
|
||||
if (appliedEmployee.value.trim() !== '') n++
|
||||
if (appliedRange.value?.start || appliedRange.value?.end) n++
|
||||
if (appliedEntityTypes.value.length > 0) n++
|
||||
if (appliedActions.value.length > 0) n++
|
||||
if (appliedUsername.value.trim() !== '') n++
|
||||
if (appliedIp.value.trim() !== '') n++
|
||||
if (appliedDevice.value.trim() !== '') n++
|
||||
return n
|
||||
})
|
||||
|
||||
const buildFilters = (): AuditLogFilters => ({
|
||||
employee: appliedEmployee.value.trim() || undefined,
|
||||
from: appliedRange.value?.start || undefined,
|
||||
to: appliedRange.value?.end || undefined,
|
||||
entityType: appliedEntityTypes.value.length > 0 ? [...appliedEntityTypes.value] : undefined,
|
||||
action: appliedActions.value.length > 0 ? [...appliedActions.value] : undefined,
|
||||
username: appliedUsername.value.trim() || undefined,
|
||||
ip: appliedIp.value.trim() || undefined,
|
||||
device: appliedDevice.value.trim() || undefined,
|
||||
page: page.value,
|
||||
perPage: perPage.value,
|
||||
})
|
||||
|
||||
// Race guard: only the latest request may commit its result.
|
||||
let requestSeq = 0
|
||||
const load = async () => {
|
||||
const seq = ++requestSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchAuditLogs(buildFilters())
|
||||
if (seq !== requestSeq) return
|
||||
items.value = result.items
|
||||
total.value = result.total
|
||||
page.value = result.page
|
||||
perPage.value = result.perPage
|
||||
} finally {
|
||||
if (seq === requestSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
await load()
|
||||
}
|
||||
|
||||
const goToPage = (n: number) => {
|
||||
page.value = n
|
||||
load()
|
||||
}
|
||||
|
||||
const setPerPage = (n: number) => {
|
||||
perPage.value = n
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
const openFilters = () => {
|
||||
draftEmployee.value = appliedEmployee.value
|
||||
draftRange.value = appliedRange.value ? { ...appliedRange.value } : null
|
||||
draftEntityTypes.value = [...appliedEntityTypes.value]
|
||||
draftActions.value = [...appliedActions.value]
|
||||
draftUsername.value = appliedUsername.value
|
||||
draftIp.value = appliedIp.value
|
||||
draftDevice.value = appliedDevice.value
|
||||
filterOpen.value = true
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
appliedEmployee.value = draftEmployee.value
|
||||
appliedRange.value = draftRange.value ? { ...draftRange.value } : null
|
||||
appliedEntityTypes.value = [...draftEntityTypes.value]
|
||||
appliedActions.value = [...draftActions.value]
|
||||
appliedUsername.value = draftUsername.value
|
||||
appliedIp.value = draftIp.value
|
||||
appliedDevice.value = draftDevice.value
|
||||
page.value = 1
|
||||
filterOpen.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
draftEmployee.value = ''
|
||||
draftRange.value = null
|
||||
draftEntityTypes.value = []
|
||||
draftActions.value = []
|
||||
draftUsername.value = ''
|
||||
draftIp.value = ''
|
||||
draftDevice.value = ''
|
||||
appliedEmployee.value = ''
|
||||
appliedRange.value = null
|
||||
appliedEntityTypes.value = []
|
||||
appliedActions.value = []
|
||||
appliedUsername.value = ''
|
||||
appliedIp.value = ''
|
||||
appliedDevice.value = ''
|
||||
page.value = 1
|
||||
load() // drawer stays open
|
||||
}
|
||||
|
||||
const toggle = (arr: typeof draftEntityTypes, value: string, selected: boolean) => {
|
||||
arr.value = selected ? [...arr.value, value] : arr.value.filter(v => v !== value)
|
||||
}
|
||||
const toggleEntityType = (value: string, selected: boolean) => toggle(draftEntityTypes, value, selected)
|
||||
const toggleAction = (value: string, selected: boolean) => toggle(draftActions, value, selected)
|
||||
|
||||
return {
|
||||
items, total, page, perPage, loading, filterOpen, activeFilterCount,
|
||||
draftEmployee, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice,
|
||||
init, goToPage, setPerPage, openFilters, applyFilters, resetFilters, toggleEntityType, toggleAction,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Stable per-device identifier used to add forensic context to audit logs.
|
||||
// Persisted in localStorage so the same browser/device reuses it across sessions.
|
||||
// NOTE: this identifies a device/browser, not a human — on a shared kiosk every
|
||||
// user of the same browser shares one id (intended: it distinguishes devices).
|
||||
|
||||
const STORAGE_KEY = 'sirh-device-id'
|
||||
let cached: string | null = null
|
||||
|
||||
export const useDeviceId = (): string | null => {
|
||||
if (!import.meta.client) {
|
||||
return null
|
||||
}
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
try {
|
||||
let id = localStorage.getItem(STORAGE_KEY)
|
||||
if (!id) {
|
||||
id = crypto.randomUUID()
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
cached = id
|
||||
return id
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, disabled) — degrade gracefully.
|
||||
return null
|
||||
}
|
||||
}
|
||||
+208
-220
@@ -1,254 +1,242 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<h1 class="text-4xl font-bold text-primary-500 pb-6">Journal des actions</h1>
|
||||
<div class="flex items-center justify-between pb-6">
|
||||
<h1 class="text-4xl font-bold text-primary-500">Journal des actions</h1>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
@click="list.openFilters()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-4 pb-6 flex-wrap">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Employé</label>
|
||||
<select
|
||||
v-model="filters.employeeId"
|
||||
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
>
|
||||
<option :value="undefined">Tous</option>
|
||||
<option v-for="emp in employees" :key="emp.id" :value="emp.id">
|
||||
{{ emp.lastName }} {{ emp.firstName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Du</label>
|
||||
<input
|
||||
v-model="filters.from"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Au</label>
|
||||
<input
|
||||
v-model="filters.to"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Type</label>
|
||||
<select
|
||||
v-model="filters.entityType"
|
||||
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
>
|
||||
<option :value="undefined">Tous</option>
|
||||
<option value="work_hour">Heures</option>
|
||||
<option value="absence">Absences</option>
|
||||
<option value="employee">Employé</option>
|
||||
<option value="contract_suspension">Suspension</option>
|
||||
<option value="rtt_payment">Paiement RTT</option>
|
||||
<option value="fractioned_days">Jours fractionnés</option>
|
||||
<option value="paid_leave_days">Congés N-1 payés</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="h-[42px] rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
@click="search"
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="list.items.value"
|
||||
:total-items="list.total.value"
|
||||
:page="list.page.value"
|
||||
:per-page="list.perPage.value"
|
||||
:per-page-options="[10, 25, 50, 100]"
|
||||
empty-message="Aucune entrée trouvée."
|
||||
@row-click="openDetail"
|
||||
@update:page="list.goToPage($event)"
|
||||
@update:per-page="list.setPerPage($event)"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
<template #cell-createdAt="{ item }">
|
||||
{{ formatDateTime((item as AuditLog).createdAt) }}
|
||||
</template>
|
||||
<template #cell-action="{ item }">
|
||||
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass((item as AuditLog).action)">
|
||||
{{ actionLabel((item as AuditLog).action) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-entityType="{ item }">
|
||||
{{ entityTypeLabel((item as AuditLog).entityType) }}
|
||||
</template>
|
||||
<template #cell-employeeName="{ item }">
|
||||
{{ (item as AuditLog).employeeName ?? '—' }}
|
||||
</template>
|
||||
<template #cell-deviceLabel="{ item }">
|
||||
{{ (item as AuditLog).deviceLabel ?? '—' }}
|
||||
</template>
|
||||
<template #cell-description="{ item }">
|
||||
<span class="block max-w-[320px] truncate" :title="(item as AuditLog).description">{{ (item as AuditLog).description }}</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Chargement...
|
||||
</div>
|
||||
<!-- Filter drawer -->
|
||||
<MalioDrawer
|
||||
v-model="list.filterOpen.value"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[32px] font-semibold text-primary-500">Filtres</h2>
|
||||
</template>
|
||||
|
||||
<div v-else-if="logs.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Aucune entrée trouvée.
|
||||
</div>
|
||||
<MalioAccordion>
|
||||
<MalioAccordionItem title="Période" value="period">
|
||||
<MalioDateRange v-model="list.draftRange.value" clearable />
|
||||
</MalioAccordionItem>
|
||||
|
||||
<template v-else>
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-white">
|
||||
<div class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
||||
<span>Date action</span>
|
||||
<span>Utilisateur</span>
|
||||
<span>Action</span>
|
||||
<span>Type</span>
|
||||
<span>Employé</span>
|
||||
<span>Description</span>
|
||||
<span>Date affectée</span>
|
||||
</div>
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<template v-for="log in logs" :key="log.id">
|
||||
<div
|
||||
class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
||||
@click="toggleExpand(log.id)"
|
||||
>
|
||||
<span>{{ formatDateTime(log.createdAt) }}</span>
|
||||
<span>{{ log.username }}</span>
|
||||
<span>
|
||||
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(log.action)">
|
||||
{{ actionLabel(log.action) }}
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ entityTypeLabel(log.entityType) }}</span>
|
||||
<span>{{ log.employeeName ?? '-' }}</span>
|
||||
<span class="truncate font-normal" :title="log.description">{{ log.description }}</span>
|
||||
<span>{{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}</span>
|
||||
<MalioAccordionItem title="Employé" value="employee">
|
||||
<MalioInputText v-model="list.draftEmployee.value" icon-name="mdi:magnify" />
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="Type d'entité" value="entityType">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in entityTypeOptions"
|
||||
:id="`filter-type-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="list.draftEntityTypes.value.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => list.toggleEntityType(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="Action" value="action">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in actionOptions"
|
||||
:id="`filter-action-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="list.draftActions.value.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => list.toggleAction(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="Utilisateur / compte" value="username">
|
||||
<MalioInputText v-model="list.draftUsername.value" icon-name="mdi:magnify" />
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="IP" value="ip">
|
||||
<MalioInputText v-model="list.draftIp.value" icon-name="mdi:magnify" />
|
||||
</MalioAccordionItem>
|
||||
|
||||
<MalioAccordionItem title="Appareil" value="device">
|
||||
<MalioInputText v-model="list.draftDevice.value" icon-name="mdi:magnify" />
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton variant="tertiary" label="Réinitialiser" @click="list.resetFilters()" />
|
||||
<MalioButton variant="primary" label="Appliquer" button-class="w-[170px]" @click="list.applyFilters()" />
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
|
||||
<!-- Detail drawer -->
|
||||
<MalioDrawer v-model="detailOpen" drawer-class="max-w-xl">
|
||||
<template #header>
|
||||
<h2 class="text-[32px] font-semibold text-primary-500">Détail de l'action</h2>
|
||||
</template>
|
||||
|
||||
<div v-if="selected" class="space-y-6 text-md text-primary-500">
|
||||
<section class="space-y-1">
|
||||
<p><span class="font-semibold">Utilisateur :</span> {{ selected.username }}</p>
|
||||
<p><span class="font-semibold">Employé :</span> {{ selected.employeeName ?? '—' }}</p>
|
||||
<p><span class="font-semibold">Date action :</span> {{ formatDateTime(selected.createdAt) }}</p>
|
||||
<p><span class="font-semibold">Date affectée :</span> {{ selected.affectedDate ? formatDate(selected.affectedDate) : '—' }}</p>
|
||||
<p>
|
||||
<span class="font-semibold">Action :</span>
|
||||
<span class="ml-1 rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(selected.action)">{{ actionLabel(selected.action) }}</span>
|
||||
</p>
|
||||
<p><span class="font-semibold">Type :</span> {{ entityTypeLabel(selected.entityType) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1">
|
||||
<h3 class="font-bold">Contexte technique</h3>
|
||||
<p><span class="font-semibold">IP :</span> {{ selected.ipAddress ?? '—' }}</p>
|
||||
<p><span class="font-semibold">Appareil :</span> {{ selected.deviceLabel ?? '—' }}</p>
|
||||
<p><span class="font-semibold">User-Agent :</span> <span class="break-all text-sm font-normal">{{ selected.userAgent ?? '—' }}</span></p>
|
||||
<p><span class="font-semibold">Device id :</span> <span class="break-all text-sm font-normal">{{ selected.deviceId ?? '—' }}</span></p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1">
|
||||
<h3 class="font-bold">Changements</h3>
|
||||
<div v-if="changeRows.length > 0" class="space-y-1">
|
||||
<div v-for="row in changeRows" :key="row.key" class="text-sm">
|
||||
<span class="font-semibold">{{ row.key }} :</span>
|
||||
<span class="text-red-600">{{ row.old }}</span>
|
||||
<span class="px-1">→</span>
|
||||
<span class="text-green-600">{{ row.new }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="expandedIds.has(log.id)"
|
||||
class="border-b border-primary-500 px-6 py-4 bg-neutral-50"
|
||||
>
|
||||
<div v-if="log.changes" class="grid grid-cols-2 gap-6 text-sm font-mono">
|
||||
<div v-if="log.changes.old">
|
||||
<p class="font-bold text-red-600 mb-2">Ancien</p>
|
||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.old, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="log.changes.new">
|
||||
<p class="font-bold text-green-600 mb-2">Nouveau</p>
|
||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.new, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-md text-neutral-400">Pas de détail disponible.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm font-normal text-neutral-400">Aucun détail de modification.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<p class="text-md text-neutral-500">
|
||||
{{ total }} résultat{{ total > 1 ? 's' : '' }} — page {{ currentPage }}/{{ totalPages }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="currentPage <= 1"
|
||||
class="rounded-lg border border-primary-500 px-4 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { AuditLog } from '~/services/dto/audit-log'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import { fetchAuditLogs } from '~/services/audit-logs'
|
||||
import { listEmployees } from '~/services/employees'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'super-admin'
|
||||
})
|
||||
import { useAuditLogsList } from '~/composables/useAuditLogsList'
|
||||
|
||||
definePageMeta({ middleware: 'super-admin' })
|
||||
useHead({ title: 'Journal des actions' })
|
||||
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const employees = ref<Employee[]>([])
|
||||
const isLoading = ref(false)
|
||||
const expandedIds = ref(new Set<number>())
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(50)
|
||||
const list = useAuditLogsList()
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
|
||||
const columns = [
|
||||
{ key: 'createdAt', label: 'Date action' },
|
||||
{ key: 'username', label: 'Utilisateur' },
|
||||
{ key: 'action', label: 'Action' },
|
||||
{ key: 'entityType', label: 'Type' },
|
||||
{ key: 'employeeName', label: 'Employé' },
|
||||
{ key: 'deviceLabel', label: 'Appareil' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
]
|
||||
|
||||
const filters = reactive<{
|
||||
employeeId?: number
|
||||
from?: string
|
||||
to?: string
|
||||
entityType?: string
|
||||
}>({})
|
||||
const entityTypeOptions = [
|
||||
{ value: 'work_hour', label: 'Heures' },
|
||||
{ value: 'absence', label: 'Absence' },
|
||||
{ value: 'employee', label: 'Employé' },
|
||||
{ value: 'contract_suspension', label: 'Suspension' },
|
||||
{ value: 'rtt_payment', label: 'RTT' },
|
||||
{ value: 'fractioned_days', label: 'Fract.' },
|
||||
{ value: 'paid_leave_days', label: 'Congés payés' },
|
||||
{ value: 'week_comment', label: 'Commentaire' },
|
||||
]
|
||||
|
||||
const loadLogs = async (page = 1) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await fetchAuditLogs({ ...filters, page })
|
||||
logs.value = result.items
|
||||
total.value = result.total
|
||||
currentPage.value = result.page
|
||||
perPage.value = result.perPage
|
||||
expandedIds.value.clear()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
const actionOptions = [
|
||||
{ value: 'create', label: 'Créer' },
|
||||
{ value: 'update', label: 'Modifier' },
|
||||
{ value: 'delete', label: 'Supprimer' },
|
||||
{ value: 'validate', label: 'Valider' },
|
||||
{ value: 'site_validate', label: 'Valider (site)' },
|
||||
]
|
||||
|
||||
const filterButtonLabel = computed(() =>
|
||||
list.activeFilterCount.value > 0 ? `Filtrer (${list.activeFilterCount.value})` : 'Filtrer',
|
||||
)
|
||||
|
||||
// Detail drawer
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref<AuditLog | null>(null)
|
||||
|
||||
const openDetail = (item: Record<string, unknown>) => {
|
||||
selected.value = item as unknown as AuditLog
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
loadLogs(1)
|
||||
}
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
loadLogs(page)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
expandedIds.value.add(id)
|
||||
}
|
||||
}
|
||||
const changeRows = computed(() => {
|
||||
const c = selected.value?.changes
|
||||
if (!c) return []
|
||||
const keys = new Set<string>([...Object.keys(c.old ?? {}), ...Object.keys(c.new ?? {})])
|
||||
return [...keys].map(key => ({
|
||||
key,
|
||||
old: c.old?.[key] === undefined ? '—' : JSON.stringify(c.old[key]),
|
||||
new: c.new?.[key] === undefined ? '—' : JSON.stringify(c.new[key]),
|
||||
}))
|
||||
})
|
||||
|
||||
const formatDateTime = (dt: string) => {
|
||||
const d = new Date(dt)
|
||||
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
return d.split('-').reverse().join('/')
|
||||
}
|
||||
const formatDate = (d: string) => d.split('-').reverse().join('/')
|
||||
|
||||
const actionLabel = (action: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
create: 'Créer',
|
||||
update: 'Modifier',
|
||||
delete: 'Suppr.',
|
||||
validate: 'Valid.',
|
||||
site_validate: 'Valid. site',
|
||||
}
|
||||
return map[action] ?? action
|
||||
}
|
||||
const actionLabel = (action: string): string => ({
|
||||
create: 'Créer', update: 'Modifier', delete: 'Suppr.', validate: 'Valid.', site_validate: 'Valid. site',
|
||||
}[action] ?? action)
|
||||
|
||||
const actionClass = (action: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
create: 'bg-green-500',
|
||||
update: 'bg-blue-500',
|
||||
delete: 'bg-red-500',
|
||||
validate: 'bg-purple-500',
|
||||
site_validate: 'bg-indigo-500',
|
||||
}
|
||||
return map[action] ?? 'bg-neutral-500'
|
||||
}
|
||||
const actionClass = (action: string): string => ({
|
||||
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500', validate: 'bg-purple-500', site_validate: 'bg-indigo-500',
|
||||
}[action] ?? 'bg-neutral-500')
|
||||
|
||||
const entityTypeLabel = (type: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
work_hour: 'Heures',
|
||||
absence: 'Absence',
|
||||
employee: 'Employé',
|
||||
contract_suspension: 'Suspension',
|
||||
rtt_payment: 'RTT',
|
||||
fractioned_days: 'Fract.',
|
||||
paid_leave_days: 'Congés payés',
|
||||
}
|
||||
return map[type] ?? type
|
||||
}
|
||||
const entityTypeLabel = (type: string): string => ({
|
||||
work_hour: 'Heures', absence: 'Absence', employee: 'Employé', contract_suspension: 'Suspension',
|
||||
rtt_payment: 'RTT', fractioned_days: 'Fract.', paid_leave_days: 'Congés payés', week_comment: 'Commentaire',
|
||||
}[type] ?? type)
|
||||
|
||||
onMounted(async () => {
|
||||
employees.value = await listEmployees()
|
||||
await loadLogs()
|
||||
})
|
||||
onMounted(() => { list.init() })
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { AuditLog } from './dto/audit-log'
|
||||
|
||||
export type AuditLogFilters = {
|
||||
employeeId?: number
|
||||
employee?: string
|
||||
from?: string
|
||||
to?: string
|
||||
entityType?: string
|
||||
entityType?: string[]
|
||||
action?: string[]
|
||||
username?: string
|
||||
ip?: string
|
||||
device?: string
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
|
||||
export type AuditLogPage = {
|
||||
@@ -17,17 +22,18 @@ export type AuditLogPage = {
|
||||
|
||||
export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise<AuditLogPage> => {
|
||||
const api = useApi()
|
||||
const params: Record<string, string> = {}
|
||||
const params: Record<string, string | string[]> = {}
|
||||
|
||||
if (filters.employeeId) params.employeeId = String(filters.employeeId)
|
||||
if (filters.employee && filters.employee.trim() !== '') params.employee = filters.employee.trim()
|
||||
if (filters.from) params.from = filters.from
|
||||
if (filters.to) params.to = filters.to
|
||||
if (filters.entityType) params.entityType = filters.entityType
|
||||
if (filters.entityType && filters.entityType.length > 0) params['entityType[]'] = filters.entityType
|
||||
if (filters.action && filters.action.length > 0) params['action[]'] = filters.action
|
||||
if (filters.username && filters.username.trim() !== '') params.username = filters.username.trim()
|
||||
if (filters.ip && filters.ip.trim() !== '') params.ip = filters.ip.trim()
|
||||
if (filters.device && filters.device.trim() !== '') params.device = filters.device.trim()
|
||||
if (filters.page) params.page = String(filters.page)
|
||||
if (filters.perPage) params.perPage = String(filters.perPage)
|
||||
|
||||
return api.get<AuditLogPage>(
|
||||
'/audit-logs',
|
||||
params,
|
||||
{ toast: false }
|
||||
)
|
||||
return api.get<AuditLogPage>('/audit-logs', params, { toast: false })
|
||||
}
|
||||
|
||||
@@ -8,5 +8,9 @@ export type AuditLog = {
|
||||
description: string
|
||||
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
|
||||
affectedDate: string | null
|
||||
ipAddress: string | null
|
||||
userAgent: string | null
|
||||
deviceLabel: string | null
|
||||
deviceId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user