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>
243 lines
9.6 KiB
Vue
243 lines
9.6 KiB
Vue
<template>
|
|
<div class="h-full flex flex-col overflow-hidden">
|
|
<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="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)"
|
|
>
|
|
<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>
|
|
|
|
<!-- 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>
|
|
|
|
<MalioAccordion>
|
|
<MalioAccordionItem title="Période" value="period">
|
|
<MalioDateRange v-model="list.draftRange.value" clearable />
|
|
</MalioAccordionItem>
|
|
|
|
<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>
|
|
<p v-else class="text-sm font-normal text-neutral-400">Aucun détail de modification.</p>
|
|
</section>
|
|
</div>
|
|
</MalioDrawer>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import type { AuditLog } from '~/services/dto/audit-log'
|
|
import { useAuditLogsList } from '~/composables/useAuditLogsList'
|
|
|
|
definePageMeta({ middleware: 'super-admin' })
|
|
useHead({ title: 'Journal des actions' })
|
|
|
|
const list = useAuditLogsList()
|
|
|
|
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 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 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 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) => d.split('-').reverse().join('/')
|
|
|
|
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 => ({
|
|
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 => ({
|
|
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(() => { list.init() })
|
|
</script>
|