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
-8
View File
@@ -65,13 +65,7 @@ frontend/src/pages/Archival/ (pagina log versamenti, download RdV, richiesta DIP
Il modello archival.py esiste ma la tabella archival_batches non e' nella migrazione corrente
La configurazione conservatore nelle impostazioni tenant e' pronta, ma il "pulsante" che avvia il versamento non esiste
5. Audit Log modello esistente, tutto il resto mancante
Il modello audit_log.py e la tabella esistono
Non c'e' nessun endpoint API GET /audit-log per leggerlo
Non c'e' nessuna pagina frontend per la visualizzazione
Non e' chiaro se il backend registra effettivamente gli eventi (nessuna chiamata a AuditLog trovata nei servizi)
COSA MANCA PRIORITA' MEDIA
6. Worker job mancanti
dispatch_notification.py notifiche automatiche
@@ -85,9 +79,7 @@ Il CI/CD GitHub Actions e' disabilitato (ci.yml.bak): non c'e' lint automatico,
Non c'e' docker-compose.prod.yml (override produzione con configurazioni rafforzate)
Docs /docs, /redoc sono disabilitate in produzione ma non c'e' un meccanismo di secret scan
9. Ruolo Supervisor
Il ruolo supervisor e' definito nell'enum DB e nella documentazione ma non ha logica differenziata dal operator nel codice: is_admin controlla solo admin/super_admin, tutto il resto e' trattato uguale
10. Gestione quote casella
L'evento mailbox.quota_warning e' definito negli enum delle notifiche ma non e' mai generato dal worker (nessuna stima della quota IMAP)
+65
View File
@@ -0,0 +1,65 @@
"""
Router Audit Log consultazione degli eventi di sistema.
Endpoint:
GET /api/v1/audit-log lista paginata con filtri (solo admin/super_admin)
Permessi:
- admin: vede solo gli eventi del proprio tenant
- super_admin: vede tutti i tenant (filtrabile per tenant_id)
"""
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, DB
from app.schemas.audit_log import AuditLogListResponse
from app.services.audit_service import AuditService
router = APIRouter(prefix="/audit-log", tags=["Audit Log"])
@router.get("", response_model=AuditLogListResponse)
async def list_audit_log(
current_user: AdminUser,
db: DB,
page: int = Query(1, ge=1, description="Numero di pagina"),
page_size: int = Query(25, ge=1, le=100, description="Elementi per pagina"),
action: Optional[str] = Query(None, description="Filtra per azione (es. auth.login, user.*)"),
user_id: Optional[uuid.UUID] = Query(None, description="Filtra per utente"),
outcome: Optional[str] = Query(None, pattern="^(success|failure)$", description="Esito: success o failure"),
date_from: Optional[datetime] = Query(None, description="Data inizio (ISO 8601)"),
date_to: Optional[datetime] = Query(None, description="Data fine (ISO 8601)"),
resource_type: Optional[str] = Query(None, description="Tipo risorsa (user, mailbox, message, ...)"),
tenant_id: Optional[uuid.UUID] = Query(None, description="Filtra per tenant (solo super_admin)"),
) -> AuditLogListResponse:
"""
Restituisce la lista paginata degli eventi di audit.
- Admin: vede solo gli eventi del proprio tenant (tenant_id ignorato).
- Super Admin: vede tutti i tenant, filtrabile per tenant_id.
"""
svc = AuditService(db)
# Determina il tenant_id effettivo da applicare al filtro
if current_user.is_super_admin:
# Super admin: usa il tenant_id passato come filtro (None = tutti)
effective_tenant_id = tenant_id
else:
# Admin normale: sempre vincolato al proprio tenant
effective_tenant_id = current_user.tenant_id
return await svc.list(
tenant_id=effective_tenant_id,
page=page,
page_size=page_size,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
+21
View File
@@ -161,13 +161,34 @@ async def totp_disable(
summary="Cambio password utente corrente",
)
async def change_password(
request: Request,
body: PasswordChangeRequest,
current_user: CurrentUser,
db: DB,
) -> None:
from app.core.security import verify_password, hash_password
from app.services.audit_service import log_audit
if not verify_password(body.current_password, current_user.password_hash):
from app.services.audit_service import log_audit as _la
await _la(
db,
"auth.password_changed",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
outcome="failure",
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
payload={"reason": "wrong_current_password"},
)
raise InvalidCredentialsError()
current_user.password_hash = hash_password(body.new_password)
await log_audit(
db,
"auth.password_changed",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
+2 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, reports, send, tenants, users, virtual_boxes, ws
from app.api.v1 import audit_log, auth, labels, mailboxes, messages, notifications, permissions, reports, send, tenants, users, virtual_boxes, ws
from app.api.v1 import settings as settings_router
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
@@ -97,6 +97,7 @@ app.include_router(notifications.router, prefix=API_PREFIX)
app.include_router(labels.router, prefix=API_PREFIX)
app.include_router(settings_router.router, prefix=API_PREFIX)
app.include_router(reports.router, prefix=API_PREFIX)
app.include_router(audit_log.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
+41
View File
@@ -0,0 +1,41 @@
"""
Schemi Pydantic per Audit Log.
"""
import uuid
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, field_validator
from app.core.pagination import PaginatedResponse
class AuditLogResponse(BaseModel):
"""Risposta singolo evento audit."""
id: int
tenant_id: Optional[uuid.UUID] = None
user_id: Optional[uuid.UUID] = None
action: str
resource_type: Optional[str] = None
resource_id: Optional[uuid.UUID] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
payload: Optional[dict] = None
outcome: str
occurred_at: datetime
model_config = {"from_attributes": True}
@field_validator("ip_address", mode="before")
@classmethod
def coerce_ip_address(cls, v: Any) -> Optional[str]:
"""Converte IPv4Address/IPv6Address (tipo PostgreSQL INET) in stringa."""
if v is None:
return None
return str(v)
# Lista paginata
AuditLogListResponse = PaginatedResponse[AuditLogResponse]
+153
View File
@@ -0,0 +1,153 @@
"""
Servizio Audit Log registrazione e consultazione degli eventi di sistema.
Uso tipico nei router/servizi:
from app.services.audit_service import log_audit
await log_audit(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.created",
resource_type="user",
resource_id=new_user.id,
outcome="success",
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
payload={"email": new_user.email},
)
"""
import math
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.pagination import PaginatedResponse, PaginationParams
from app.models.audit_log import AuditLog
from app.schemas.audit_log import AuditLogResponse
# ─── Helper standalone (da chiamare ovunque senza istanziare la classe) ───────
async def log_audit(
db: AsyncSession,
action: str,
*,
tenant_id: Optional[uuid.UUID] = None,
user_id: Optional[uuid.UUID] = None,
resource_type: Optional[str] = None,
resource_id: Optional[uuid.UUID] = None,
outcome: str = "success",
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
payload: Optional[dict] = None,
) -> None:
"""
Inserisce un record di audit log nella sessione corrente.
Non fa commit: il commit avviene con la transazione del chiamante.
Non solleva eccezioni: gli errori sono loggati ma non propagati
per evitare di bloccare l'operazione principale.
"""
try:
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
payload=payload or {},
outcome=outcome,
)
db.add(entry)
except Exception:
# Mai bloccare l'operazione principale per un errore di audit
import logging
logging.getLogger(__name__).warning(
"Impossibile registrare evento audit: action=%s", action, exc_info=True
)
# ─── Servizio per query (usato dal router) ────────────────────────────────────
class AuditService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list(
self,
*,
tenant_id: Optional[uuid.UUID],
page: int = 1,
page_size: int = 25,
action: Optional[str] = None,
user_id: Optional[uuid.UUID] = None,
outcome: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
resource_type: Optional[str] = None,
) -> PaginatedResponse[AuditLogResponse]:
"""
Restituisce la lista paginata degli eventi audit.
Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant.
"""
filters = []
if tenant_id is not None:
filters.append(AuditLog.tenant_id == tenant_id)
if action:
# Supporta prefisso: "auth." corrisponde a tutti gli eventi auth.*
if action.endswith("*"):
filters.append(AuditLog.action.like(action[:-1] + "%"))
else:
filters.append(AuditLog.action == action)
if user_id:
filters.append(AuditLog.user_id == user_id)
if outcome:
filters.append(AuditLog.outcome == outcome)
if date_from:
filters.append(AuditLog.occurred_at >= date_from)
if date_to:
filters.append(AuditLog.occurred_at <= date_to)
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
where_clause = and_(*filters) if filters else True # type: ignore[arg-type]
# Count totale
count_q = select(func.count()).select_from(AuditLog).where(where_clause)
total = (await self.db.execute(count_q)).scalar_one()
# Dati paginati
offset = (page - 1) * page_size
items_q = (
select(AuditLog)
.where(where_clause)
.order_by(AuditLog.occurred_at.desc())
.offset(offset)
.limit(page_size)
)
result = await self.db.execute(items_q)
items = list(result.scalars().all())
pages = math.ceil(total / page_size) if page_size > 0 else 0
return PaginatedResponse[AuditLogResponse](
items=[AuditLogResponse.model_validate(item) for item in items],
total=total,
page=page,
page_size=page_size,
pages=pages,
)
+27
View File
@@ -12,6 +12,7 @@ from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
from app.core.security import decrypt_credential, encrypt_credential
from app.models.mailbox import Mailbox
from app.models.tenant import Tenant
from app.services.audit_service import log_audit
from app.schemas.mailbox import (
ConnectionTestRequest,
ConnectionTestResult,
@@ -85,6 +86,15 @@ class MailboxService:
)
self.db.add(mailbox)
await self.db.flush()
await log_audit(
self.db,
"mailbox.created",
tenant_id=tenant_id,
user_id=created_by,
resource_type="mailbox",
resource_id=mailbox.id,
payload={"email_address": mailbox.email_address},
)
return mailbox
async def list_mailboxes(
@@ -175,6 +185,14 @@ class MailboxService:
mailbox.status = "active"
await self.db.flush()
await log_audit(
self.db,
"mailbox.updated",
tenant_id=tenant_id,
resource_type="mailbox",
resource_id=mailbox_id,
payload={"mailbox_id": str(mailbox_id)},
)
return mailbox
async def delete_mailbox(
@@ -184,8 +202,17 @@ class MailboxService:
) -> None:
"""Soft-delete: imposta status=deleted."""
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
email = mailbox.email_address
mailbox.status = "deleted"
await self.db.flush()
await log_audit(
self.db,
"mailbox.deleted",
tenant_id=tenant_id,
resource_type="mailbox",
resource_id=mailbox_id,
payload={"email_address": email},
)
# ─── Decrypt helpers (usati internamente e dal worker) ───────────────────
+32
View File
@@ -13,6 +13,7 @@ from app.core.security import hash_password
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.user import UserCreateRequest, UserUpdateRequest
from app.services.audit_service import log_audit
class UserService:
@@ -61,6 +62,15 @@ class UserService:
)
self.db.add(user)
await self.db.flush() # ottieni l'ID
await log_audit(
self.db,
"user.created",
tenant_id=tenant_id,
user_id=created_by.id,
resource_type="user",
resource_id=user.id,
payload={"email": user.email, "role": user.role},
)
return user
async def get_user(self, user_id: uuid.UUID, tenant_id: uuid.UUID) -> User:
@@ -110,13 +120,26 @@ class UserService:
if user.is_super_admin and not updated_by.is_super_admin:
raise ForbiddenError("Non puoi modificare un super_admin")
changes: dict = {}
if data.full_name is not None:
changes["full_name"] = data.full_name
user.full_name = data.full_name
if data.role is not None:
changes["role"] = data.role
user.role = data.role
if data.is_active is not None:
changes["is_active"] = data.is_active
user.is_active = data.is_active
await log_audit(
self.db,
"user.updated",
tenant_id=tenant_id,
user_id=updated_by.id,
resource_type="user",
resource_id=user_id,
payload={"changes": changes},
)
return user
async def reset_password(
@@ -143,3 +166,12 @@ class UserService:
# Soft delete (disabilita invece di eliminare)
user.is_active = False
await log_audit(
self.db,
"user.deleted",
tenant_id=tenant_id,
user_id=deleted_by.id,
resource_type="user",
resource_id=user_id,
payload={"email": user.email},
)
+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