Ruolo supervisor

This commit is contained in:
2026-03-27 14:43:42 +01:00
parent ab6db28449
commit d7ae840ac6
9 changed files with 166 additions and 81 deletions
-10
View File
@@ -64,12 +64,7 @@ backend/app/api/v1/archival.py (endpoint GET /archival/batches, POST /archival/d
frontend/src/pages/Archival/ (pagina log versamenti, download RdV, richiesta DIP) 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 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 La configurazione conservatore nelle impostazioni tenant e' pronta, ma il "pulsante" che avvia il versamento non esiste
4. Dashboard e Reportistica (Fase 7 completamente mancante)
Non esistono endpoint /reports/summary, /reports/export
Non esiste pagina Reports/Dashboard nel frontend (nessuna rotta in App.tsx)
Non c'e' generazione PDF (WeasyPrint) ne' export CSV
Non c'e' nessun grafico o KPI visibile (PEC ricevute/inviate oggi, anomalie, tasso consegna)
5. Audit Log modello esistente, tutto il resto mancante 5. Audit Log modello esistente, tutto il resto mancante
Il modello audit_log.py e la tabella esistono Il modello audit_log.py e la tabella esistono
@@ -89,12 +84,7 @@ La cifratura dei segreti notifiche usa base64.b64encode() senza encryption reale
Il CI/CD GitHub Actions e' disabilitato (ci.yml.bak): non c'e' lint automatico, test o build su PR Il CI/CD GitHub Actions e' disabilitato (ci.yml.bak): non c'e' lint automatico, test o build su PR
Non c'e' docker-compose.prod.yml (override produzione con configurazioni rafforzate) 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 Docs /docs, /redoc sono disabilitate in produzione ma non c'e' un meccanismo di secret scan
8. Invio PEC funzionalita' mancanti
Non c'e' Forward messaggio (la risposta e' parzialmente implementata in ComposePage ma non e' chiaro se funziona end-to-end)
Non c'e' endpoint per forzare un re-sync manuale di una casella (utile dopo un errore di connessione)
Non c'e' indicazione visiva del numero di messaggi non letti nella sidebar per casella
La barra ricerca nell'Inbox non ha filtri per data (da/a), stato PEC, tipo PEC
9. Ruolo Supervisor 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 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
+6 -4
View File
@@ -77,15 +77,15 @@ async def list_mailboxes(
""" """
svc = _svc(db) svc = _svc(db)
if current_user.is_admin: if current_user.is_supervisor_or_admin:
# Admin: tutte le caselle del tenant # Admin e supervisor: tutte le caselle del tenant
items, total = await svc.list_mailboxes( items, total = await svc.list_mailboxes(
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )
else: else:
# Operatori: caselle con permesso # Operator e readonly: caselle con permesso esplicito
from app.services.permission_service import PermissionService from app.services.permission_service import PermissionService
perm_svc = PermissionService(db) perm_svc = PermissionService(db)
visible_ids = await perm_svc.get_visible_mailboxes(current_user) visible_ids = await perm_svc.get_visible_mailboxes(current_user)
@@ -140,7 +140,9 @@ async def get_unread_counts(
from app.models.message import Message from app.models.message import Message
# Determina le caselle visibili # Determina le caselle visibili
if current_user.is_admin: # Admin e supervisor: nessun filtro (accesso a tutto il tenant)
# Operator e readonly: solo caselle con permesso esplicito can_read
if current_user.is_supervisor_or_admin:
visible_ids = None # nessun filtro visible_ids = None # nessun filtro
else: else:
from app.services.permission_service import PermissionService from app.services.permission_service import PermissionService
+7 -4
View File
@@ -96,11 +96,14 @@ async def _get_visible_mailbox_ids(
user, db: AsyncSession user, db: AsyncSession
) -> Optional[list[uuid.UUID]]: ) -> Optional[list[uuid.UUID]]:
""" """
Per utenti non-admin restituisce la lista di mailbox_id accessibili. Per utenti non-admin/supervisor restituisce la lista di mailbox_id accessibili.
Restituisce None se l'utente e admin (accesso illimitato al tenant). Restituisce None se l'utente e' admin o supervisor (accesso illimitato al tenant).
Admin e supervisor: None (nessun filtro, query diretta sull'intero tenant).
Operator e readonly: lista esplicita di caselle con can_read=True.
""" """
if user.is_admin: if user.is_supervisor_or_admin:
return None # nessun filtro per admin return None # nessun filtro per admin e supervisor
from app.services.permission_service import PermissionService from app.services.permission_service import PermissionService
perm_svc = PermissionService(db) perm_svc = PermissionService(db)
+15
View File
@@ -153,6 +153,20 @@ async def require_super_admin(
return current_user return current_user
async def require_supervisor_or_admin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""
Richiede ruolo supervisor, admin o super_admin.
Il supervisor ha accesso in lettura implicito a tutte le caselle del tenant
ma non puo' gestire la configurazione (caselle, utenti, permessi, impostazioni).
"""
if not current_user.is_supervisor_or_admin:
raise ForbiddenError("Richiesto ruolo supervisore o amministratore")
return current_user
# ─── Protezione endpoint admin con X-Admin-Key header ───────────────────────── # ─── Protezione endpoint admin con X-Admin-Key header ─────────────────────────
async def verify_admin_key( async def verify_admin_key(
@@ -176,4 +190,5 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
CurrentTenant = Annotated[Tenant, Depends(get_current_tenant)] CurrentTenant = Annotated[Tenant, Depends(get_current_tenant)]
AdminUser = Annotated[User, Depends(require_admin)] AdminUser = Annotated[User, Depends(require_admin)]
SuperAdminUser = Annotated[User, Depends(require_super_admin)] SuperAdminUser = Annotated[User, Depends(require_super_admin)]
SupervisorOrAdminUser = Annotated[User, Depends(require_supervisor_or_admin)]
DB = Annotated[AsyncSession, Depends(get_db)] DB = Annotated[AsyncSession, Depends(get_db)]
+10
View File
@@ -86,6 +86,16 @@ class User(Base):
def is_super_admin(self) -> bool: def is_super_admin(self) -> bool:
return self.role == "super_admin" return self.role == "super_admin"
@property
def is_supervisor(self) -> bool:
"""Ruolo supervisor: lettura implicita su tutte le caselle, senza poteri di gestione."""
return self.role == "supervisor"
@property
def is_supervisor_or_admin(self) -> bool:
"""True per super_admin, admin e supervisor (accesso in lettura a tutto il tenant)."""
return self.role in ("super_admin", "admin", "supervisor")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<User {self.email!r} role={self.role!r}>" return f"<User {self.email!r} role={self.role!r}>"
+22 -11
View File
@@ -27,9 +27,13 @@ class PermissionService:
async def get_visible_mailboxes( async def get_visible_mailboxes(
self, user: User self, user: User
) -> list[uuid.UUID]: ) -> list[uuid.UUID]:
"""Restituisce gli UUID delle caselle visibili all'utente.""" """Restituisce gli UUID delle caselle visibili all'utente.
if user.role in ("super_admin", "admin"):
# Admin vede tutte le caselle del tenant Admin e supervisor vedono tutte le caselle del tenant.
Operator e readonly vedono solo le caselle con can_read=True esplicito.
"""
if user.role in ("super_admin", "admin", "supervisor"):
# Admin e supervisor vedono tutte le caselle del tenant
result = await self.db.execute( result = await self.db.execute(
select(Mailbox.id).where( select(Mailbox.id).where(
Mailbox.tenant_id == user.tenant_id, Mailbox.tenant_id == user.tenant_id,
@@ -38,7 +42,7 @@ class PermissionService:
) )
return [row[0] for row in result.all()] return [row[0] for row in result.all()]
# Operatori: solo caselle con can_read=True # Operator e readonly: solo caselle con can_read=True esplicito
result = await self.db.execute( result = await self.db.execute(
select(MailboxPermission.mailbox_id).where( select(MailboxPermission.mailbox_id).where(
MailboxPermission.user_id == user.id, MailboxPermission.user_id == user.id,
@@ -50,9 +54,13 @@ class PermissionService:
async def check_can_read( async def check_can_read(
self, user: User, mailbox_id: uuid.UUID self, user: User, mailbox_id: uuid.UUID
) -> bool: ) -> bool:
"""Verifica se l'utente può leggere i messaggi della casella.""" """Verifica se l'utente puo' leggere i messaggi della casella.
if user.role in ("super_admin", "admin"):
# Verifica solo che la casella appartenga al tenant Admin e supervisor hanno accesso implicito a tutte le caselle del tenant.
Operator e readonly richiedono permesso esplicito can_read.
"""
if user.role in ("super_admin", "admin", "supervisor"):
# Admin e supervisor: verifica solo che la casella appartenga al tenant
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id) return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id) perm = await self._get_permission(user.id, mailbox_id)
@@ -62,12 +70,15 @@ class PermissionService:
self, user: User, mailbox_id: uuid.UUID self, user: User, mailbox_id: uuid.UUID
) -> bool: ) -> bool:
""" """
Verifica se l'utente può inviare dalla casella. Verifica se l'utente puo' inviare dalla casella.
L'accesso in invio è concesso se: L'accesso in invio e' concesso se:
1. L'utente è admin del tenant, oppure 1. L'utente e' admin del tenant, oppure
2. L'utente ha un permesso diretto can_send sulla casella, oppure 2. L'utente ha un permesso diretto can_send sulla casella, oppure
3. L'utente è assegnato a una Virtual Box attiva che include la casella. 3. L'utente e' assegnato a una Virtual Box attiva che include la casella.
Nota: il supervisor NON ha invio implicito richiede can_send esplicito
come operator, ma diversamente da operator vede tutte le caselle.
""" """
if user.role in ("super_admin", "admin"): if user.role in ("super_admin", "admin"):
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id) return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
+35 -1
View File
@@ -75,7 +75,7 @@ export function Sidebar() {
/** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */ /** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */
const [collapsedVboxes, setCollapsedVboxes] = useState<Set<string>>(new Set()) const [collapsedVboxes, setCollapsedVboxes] = useState<Set<string>>(new Set())
const { user, isAdmin, isSuperAdmin, logout } = useAuth() const { user, isAdmin, isSuperAdmin, isSupervisor, logout } = useAuth()
const unreadCount = useInboxStore((s) => s.unreadCount) const unreadCount = useInboxStore((s) => s.unreadCount)
// Le caselle PEC vengono caricate qui e condivise via React Query cache // Le caselle PEC vengono caricate qui e condivise via React Query cache
@@ -416,6 +416,40 @@ export function Sidebar() {
</div> </div>
</div> </div>
{/* ── Sezione Supervisione visibile solo ai supervisor ── */}
{isSupervisor && (
<div>
{!collapsed && (
<>
<div className="border-t border-gray-700 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-cyan-400 uppercase tracking-wider">
Supervisione
</p>
</>
)}
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
<NavLink
to="/mailboxes"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-cyan-700 text-white'
: 'text-cyan-300 hover:bg-cyan-900/40 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Caselle PEC' : undefined}
>
<MailCheck className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Caselle PEC</span>}
</NavLink>
</div>
</div>
)}
{/* ── Sezione Amministrazione ── */} {/* ── Sezione Amministrazione ── */}
{isAdmin && ( {isAdmin && (
<div> <div>
+12
View File
@@ -2,6 +2,14 @@ import { useAuthStore } from '@/store/auth.store'
/** /**
* Hook helper per accedere all'utente corrente e ai permessi. * Hook helper per accedere all'utente corrente e ai permessi.
*
* Gerarchia ruoli:
* super_admin > admin > supervisor > operator > readonly
*
* Supervisor:
* - Lettura implicita su tutte le caselle del tenant
* - Invio solo se ha permesso esplicito can_send sulla casella
* - Non puo' gestire caselle, utenti, permessi o impostazioni
*/ */
export function useAuth() { export function useAuth() {
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
@@ -11,6 +19,8 @@ export function useAuth() {
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin' const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'
const isSuperAdmin = user?.role === 'super_admin' const isSuperAdmin = user?.role === 'super_admin'
const isSupervisor = user?.role === 'supervisor'
const isSupervisorOrAdmin = isAdmin || isSupervisor
const canSend = user?.role !== 'readonly' const canSend = user?.role !== 'readonly'
const canManage = isAdmin const canManage = isAdmin
@@ -20,6 +30,8 @@ export function useAuth() {
isLoading, isLoading,
isAdmin, isAdmin,
isSuperAdmin, isSuperAdmin,
isSupervisor,
isSupervisorOrAdmin,
canSend, canSend,
canManage, canManage,
logout, logout,
+59 -51
View File
@@ -27,6 +27,7 @@ import { mailboxesApi } from '@/api/mailboxes.api'
import { getErrorMessage } from '@/api/client' import { getErrorMessage } from '@/api/client'
import { formatDate, MAILBOX_STATUS_LABELS } from '@/lib/utils' import { formatDate, MAILBOX_STATUS_LABELS } from '@/lib/utils'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
import type { MailboxCreateRequest, MailboxResponse } from '@/types/api.types' import type { MailboxCreateRequest, MailboxResponse } from '@/types/api.types'
const STATUS_COLORS = { const STATUS_COLORS = {
@@ -45,6 +46,7 @@ const STATUS_ICONS = {
export function MailboxesPage() { export function MailboxesPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { isAdmin } = useAuth()
const [showCreateDialog, setShowCreateDialog] = useState(false) const [showCreateDialog, setShowCreateDialog] = useState(false)
const [editingMailbox, setEditingMailbox] = useState<MailboxResponse | null>(null) const [editingMailbox, setEditingMailbox] = useState<MailboxResponse | null>(null)
const [testingId, setTestingId] = useState<string | null>(null) const [testingId, setTestingId] = useState<string | null>(null)
@@ -112,10 +114,12 @@ export function MailboxesPage() {
<h1 className="text-xl font-semibold">Caselle PEC</h1> <h1 className="text-xl font-semibold">Caselle PEC</h1>
<span className="text-sm text-muted-foreground">({mailboxes.length})</span> <span className="text-sm text-muted-foreground">({mailboxes.length})</span>
</div> </div>
<Button onClick={() => setShowCreateDialog(true)}> {isAdmin && (
<Plus className="h-4 w-4 mr-2" /> <Button onClick={() => setShowCreateDialog(true)}>
Aggiungi casella <Plus className="h-4 w-4 mr-2" />
</Button> Aggiungi casella
</Button>
)}
</div> </div>
{/* Contenuto */} {/* Contenuto */}
@@ -129,12 +133,14 @@ export function MailboxesPage() {
<MailCheck className="h-12 w-12 text-muted-foreground/30 mb-3" /> <MailCheck className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Nessuna casella PEC configurata</p> <p className="text-muted-foreground font-medium">Nessuna casella PEC configurata</p>
<p className="text-sm text-muted-foreground/70 mt-1"> <p className="text-sm text-muted-foreground/70 mt-1">
Aggiungi una casella per iniziare a gestire le PEC {isAdmin ? 'Aggiungi una casella per iniziare a gestire le PEC' : 'Nessuna casella disponibile'}
</p> </p>
<Button className="mt-4" onClick={() => setShowCreateDialog(true)}> {isAdmin && (
<Plus className="h-4 w-4 mr-2" /> <Button className="mt-4" onClick={() => setShowCreateDialog(true)}>
Aggiungi casella <Plus className="h-4 w-4 mr-2" />
</Button> Aggiungi casella
</Button>
)}
</div> </div>
) : ( ) : (
<div className="grid gap-4"> <div className="grid gap-4">
@@ -186,51 +192,53 @@ export function MailboxesPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> {isAdmin && (
{/* Test connessione */} <div className="flex items-center gap-2 flex-shrink-0">
<Button {/* Test connessione */}
variant="outline" <Button
size="sm" variant="outline"
onClick={() => handleTest(mailbox)} size="sm"
isLoading={testingId === mailbox.id} onClick={() => handleTest(mailbox)}
title="Testa connessione IMAP" isLoading={testingId === mailbox.id}
> title="Testa connessione IMAP"
<TestTube className="h-4 w-4 mr-1" /> >
Test <TestTube className="h-4 w-4 mr-1" />
</Button> Test
</Button>
{/* Forza sincronizzazione */} {/* Forza sincronizzazione */}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleForceSync(mailbox)} onClick={() => handleForceSync(mailbox)}
isLoading={syncingId === mailbox.id} isLoading={syncingId === mailbox.id}
title="Forza sincronizzazione IMAP immediata" title="Forza sincronizzazione IMAP immediata"
disabled={mailbox.status === 'deleted'} disabled={mailbox.status === 'deleted'}
> >
<RefreshCw className="h-4 w-4 mr-1" /> <RefreshCw className="h-4 w-4 mr-1" />
Sync Sync
</Button> </Button>
{/* Modifica */} {/* Modifica */}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setEditingMailbox(mailbox)} onClick={() => setEditingMailbox(mailbox)}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
{/* Elimina */} {/* Elimina */}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleDelete(mailbox)} onClick={() => handleDelete(mailbox)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground" className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
)}
</div> </div>
) )
})} })}