mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Audit Log
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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) ───────────────────
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user