832751d1ed
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>
145 lines
4.7 KiB
TypeScript
145 lines
4.7 KiB
TypeScript
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,
|
|
}
|
|
}
|