Audit Log

This commit is contained in:
2026-03-27 14:58:12 +01:00
parent d7ae840ac6
commit a3247a69b6
13 changed files with 734 additions and 9 deletions
+4
View File
@@ -13,6 +13,7 @@ import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
import { SearchPage } from '@/pages/Search/SearchPage'
import { ReportsPage } from '@/pages/Reports/ReportsPage'
import { AuditLogPage } from '@/pages/AuditLog/AuditLogPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -84,6 +85,9 @@ export default function App() {
{/* Dashboard e Reportistica */}
<Route path="/reports" element={<ReportsPage />} />
{/* Audit Log */}
<Route path="/audit-log" element={<AuditLogPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+41
View File
@@ -0,0 +1,41 @@
/**
* Client API per Audit Log.
*/
import apiClient from './client'
import type { PaginatedResponse } from '@/types/api.types'
export interface AuditLogEntry {
id: number
tenant_id: string | null
user_id: string | null
action: string
resource_type: string | null
resource_id: string | null
ip_address: string | null
user_agent: string | null
payload: Record<string, unknown> | null
outcome: 'success' | 'failure'
occurred_at: string
}
export type AuditLogListResponse = PaginatedResponse<AuditLogEntry>
export interface AuditLogParams {
page?: number
page_size?: number
action?: string
user_id?: string
outcome?: 'success' | 'failure'
date_from?: string
date_to?: string
resource_type?: string
tenant_id?: string
}
export const auditLogApi = {
list: (params: AuditLogParams = {}): Promise<AuditLogListResponse> =>
apiClient
.get<AuditLogListResponse>('/audit-log', { params })
.then((r) => r.data),
}
@@ -52,6 +52,7 @@ import {
Trash2,
Search,
BarChart2,
ClipboardList,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
@@ -470,6 +471,7 @@ export function Sidebar() {
{ to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
] as const).map((item) => (
<NavLink
key={item.to}
@@ -0,0 +1,338 @@
/**
* Pagina Audit Log visualizzazione eventi di sistema.
*
* Accessibile solo ad admin e super_admin.
* Mostra una tabella paginata con filtri per data, azione ed esito.
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { format } from 'date-fns'
import { it } from 'date-fns/locale'
import { ShieldCheck, AlertCircle, Search, RotateCcw } from 'lucide-react'
import { auditLogApi } from '@/api/audit_log.api'
import type { AuditLogParams } from '@/api/audit_log.api'
import { cn } from '@/lib/utils'
// ─── Badge esito ──────────────────────────────────────────────────────────────
function OutcomeBadge({ outcome }: { outcome: string }) {
const isSuccess = outcome === 'success'
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
isSuccess
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800',
)}
>
{isSuccess ? (
<ShieldCheck className="h-3 w-3" />
) : (
<AlertCircle className="h-3 w-3" />
)}
{isSuccess ? 'Successo' : 'Fallito'}
</span>
)
}
// ─── Etichetta azione leggibile ───────────────────────────────────────────────
function actionLabel(action: string): string {
const map: Record<string, string> = {
'auth.login': 'Login',
'auth.password_changed': 'Cambio password',
'user.created': 'Utente creato',
'user.updated': 'Utente modificato',
'user.deleted': 'Utente eliminato',
'mailbox.created': 'Casella creata',
'mailbox.updated': 'Casella modificata',
'mailbox.deleted': 'Casella eliminata',
'message.sent': 'PEC inviata',
}
return map[action] ?? action
}
// ─── Componente principale ────────────────────────────────────────────────────
export function AuditLogPage() {
const [page, setPage] = useState(1)
const PAGE_SIZE = 25
// Filtri
const [filterAction, setFilterAction] = useState('')
const [filterOutcome, setFilterOutcome] = useState<'' | 'success' | 'failure'>('')
const [filterDateFrom, setFilterDateFrom] = useState('')
const [filterDateTo, setFilterDateTo] = useState('')
// Parametri query attivi (applicati al click su "Cerca")
const [activeParams, setActiveParams] = useState<AuditLogParams>({})
const { data, isLoading, isError } = useQuery({
queryKey: ['audit-log', page, activeParams],
queryFn: () =>
auditLogApi.list({
page,
page_size: PAGE_SIZE,
...activeParams,
}),
staleTime: 30_000,
})
const handleSearch = () => {
setPage(1)
const params: AuditLogParams = {}
if (filterAction) params.action = filterAction
if (filterOutcome) params.outcome = filterOutcome
if (filterDateFrom) params.date_from = new Date(filterDateFrom).toISOString()
if (filterDateTo) params.date_to = new Date(filterDateTo + 'T23:59:59').toISOString()
setActiveParams(params)
}
const handleReset = () => {
setFilterAction('')
setFilterOutcome('')
setFilterDateFrom('')
setFilterDateTo('')
setActiveParams({})
setPage(1)
}
const items = data?.items ?? []
const total = data?.total ?? 0
const pages = data?.pages ?? 1
return (
<div className="p-6 space-y-6">
{/* Intestazione */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Audit Log</h1>
<p className="text-sm text-gray-500 mt-1">
Registro cronologico degli eventi di sistema e delle operazioni degli utenti.
</p>
</div>
{/* Filtri */}
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Azione */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Azione</label>
<select
value={filterAction}
onChange={(e) => setFilterAction(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Tutte le azioni</option>
<option value="auth.login">Login</option>
<option value="auth.password_changed">Cambio password</option>
<option value="user.created">Utente creato</option>
<option value="user.updated">Utente modificato</option>
<option value="user.deleted">Utente eliminato</option>
<option value="mailbox.created">Casella creata</option>
<option value="mailbox.updated">Casella modificata</option>
<option value="mailbox.deleted">Casella eliminata</option>
<option value="message.sent">PEC inviata</option>
</select>
</div>
{/* Esito */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Esito</label>
<select
value={filterOutcome}
onChange={(e) => setFilterOutcome(e.target.value as '' | 'success' | 'failure')}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Tutti</option>
<option value="success">Successo</option>
<option value="failure">Fallito</option>
</select>
</div>
{/* Data da */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Dal</label>
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Data a */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Al</label>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Bottoni */}
<div className="flex gap-2 mt-4">
<button
onClick={handleSearch}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
<Search className="h-4 w-4" />
Cerca
</button>
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 transition-colors"
>
<RotateCcw className="h-4 w-4" />
Reimposta
</button>
</div>
</div>
{/* Tabella */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{/* Header tabella con count */}
<div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<span className="text-sm text-gray-500">
{isLoading ? 'Caricamento...' : `${total.toLocaleString('it-IT')} eventi trovati`}
</span>
{total > 0 && (
<span className="text-sm text-gray-400">
Pagina {page} di {pages}
</span>
)}
</div>
{/* Stato errore */}
{isError && (
<div className="p-8 text-center text-red-600">
<AlertCircle className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm">Errore nel caricamento dei dati.</p>
</div>
)}
{/* Stato vuoto */}
{!isLoading && !isError && items.length === 0 && (
<div className="p-8 text-center text-gray-500">
<ShieldCheck className="h-8 w-8 mx-auto mb-2 text-gray-300" />
<p className="text-sm">Nessun evento trovato.</p>
</div>
)}
{/* Dati */}
{items.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Data / Ora
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Azione
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Esito
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Risorsa
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utente
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{items.map((entry) => (
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
{/* Data/ora */}
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
{format(new Date(entry.occurred_at), 'dd/MM/yyyy HH:mm:ss', { locale: it })}
</td>
{/* Azione */}
<td className="px-4 py-3 text-sm whitespace-nowrap">
<span className="font-medium text-gray-900">
{actionLabel(entry.action)}
</span>
<span className="ml-1 text-gray-400 font-mono text-xs">
({entry.action})
</span>
</td>
{/* Esito */}
<td className="px-4 py-3 whitespace-nowrap">
<OutcomeBadge outcome={entry.outcome} />
</td>
{/* Risorsa */}
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
{entry.resource_type ? (
<span>
{entry.resource_type}
{entry.resource_id && (
<span className="ml-1 text-gray-400 font-mono text-xs">
{entry.resource_id.split('-')[0]}...
</span>
)}
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
{/* IP */}
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap font-mono">
{entry.ip_address ?? <span className="text-gray-300"></span>}
</td>
{/* Utente (UUID abbreviato) */}
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap font-mono">
{entry.user_id ? (
<span title={entry.user_id}>
{entry.user_id.split('-')[0]}...
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Paginazione */}
{pages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50 transition-colors"
>
Precedente
</button>
<span className="text-sm text-gray-500">
{page} / {pages}
</span>
<button
disabled={page >= pages}
onClick={() => setPage((p) => p + 1)}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50 transition-colors"
>
Successiva
</button>
</div>
)}
</div>
</div>
)
}
+8
View File
@@ -578,6 +578,14 @@ export interface WsEvent {
// ─── API Pagination ──────────────────────────────────────────────────────────
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
page_size: number
pages: number
}
export interface PaginationParams {
page?: number
page_size?: number