This commit is contained in:
2026-03-18 20:54:43 +01:00
parent b3c8b77f12
commit 9fe656b34c
8058 changed files with 912898 additions and 23 deletions
+55
View File
@@ -0,0 +1,55 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AppLayout } from '@/components/Layout/AppLayout'
import { LoginPage } from '@/pages/Login/LoginPage'
import { InboxPage } from '@/pages/Inbox/InboxPage'
import { MessageDetailPage } from '@/pages/MessageDetail/MessageDetailPage'
import { ComposePage } from '@/pages/Compose/ComposePage'
import { MailboxesPage } from '@/pages/Mailboxes/MailboxesPage'
import { UsersPage } from '@/pages/Users/UsersPage'
import { PermissionsPage } from '@/pages/Permissions/PermissionsPage'
/**
* Routing principale dell'applicazione PecFlow.
*
* Struttura:
* - /login → LoginPage (pubblica)
* - /* → AppLayout (richiede autenticazione)
* - /inbox → InboxPage
* - /sent → InboxPage (filtrata su outbound)
* - /messages/:id → MessageDetailPage
* - /compose → ComposePage
* - /mailboxes → MailboxesPage (admin)
* - /users → UsersPage (admin)
* - /permissions → PermissionsPage (admin)
* - / → redirect a /inbox
*/
export default function App() {
return (
<BrowserRouter>
<Routes>
{/* Pagine pubbliche */}
<Route path="/login" element={<LoginPage />} />
{/* Pagine protette (dentro AppLayout) */}
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/inbox" replace />} />
<Route path="/inbox" element={<InboxPage />} />
<Route
path="/sent"
element={<InboxPage />}
/>
<Route path="/messages/:id" element={<MessageDetailPage />} />
<Route path="/compose" element={<ComposePage />} />
{/* Pagine admin */}
<Route path="/mailboxes" element={<MailboxesPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/permissions" element={<PermissionsPage />} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/inbox" replace />} />
</Route>
</Routes>
</BrowserRouter>
)
}
+61
View File
@@ -0,0 +1,61 @@
import apiClient from './client'
import type {
LoginRequest,
TokenResponse,
TOTPSetupResponse,
TOTPStatusResponse,
UserResponse,
} from '@/types/api.types'
export const authApi = {
/**
* Login con email + password (+ opzionale codice TOTP).
*/
login: (data: LoginRequest) =>
apiClient.post<TokenResponse>('/auth/login', data).then((r) => r.data),
/**
* Rinnova access token con refresh token.
*/
refresh: (refreshToken: string) =>
apiClient
.post<TokenResponse>('/auth/refresh', { refresh_token: refreshToken })
.then((r) => r.data),
/**
* Revoca il refresh token (logout).
*/
logout: (refreshToken: string) =>
apiClient.post('/auth/logout', { refresh_token: refreshToken }),
/**
* Recupera l'utente corrente autenticato.
*/
me: () => apiClient.get<UserResponse>('/auth/me').then((r) => r.data),
/**
* Setup 2FA TOTP: restituisce QR code e segreto.
*/
totpSetup: () =>
apiClient.post<TOTPSetupResponse>('/auth/totp/setup').then((r) => r.data),
/**
* Verifica e attiva il TOTP.
*/
totpVerify: (totp_code: string) =>
apiClient
.post<TOTPStatusResponse>('/auth/totp/verify', { totp_code })
.then((r) => r.data),
/**
* Disabilita il TOTP.
*/
totpDisable: () =>
apiClient.post<TOTPStatusResponse>('/auth/totp/disable').then((r) => r.data),
/**
* Cambio password utente corrente.
*/
changePassword: (current_password: string, new_password: string) =>
apiClient.post('/auth/change-password', { current_password, new_password }),
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Client Axios base con interceptor per refresh token silenzioso.
*
* Flusso:
* 1. Ogni richiesta include il Bearer token dall'auth store
* 2. Se la risposta è 401, tenta il refresh silenzioso
* 3. Se il refresh fallisce, fa logout e redirige a /login
*/
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
const BASE_URL = '/api/v1'
// Flag per evitare loop infiniti di refresh
let isRefreshing = false
let pendingRequests: Array<(token: string) => void> = []
function processPendingRequests(token: string) {
pendingRequests.forEach((cb) => cb(token))
pendingRequests = []
}
export const apiClient = axios.create({
baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 30000,
})
// ─── Request Interceptor: inietta access token ────────────────────────────────
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Legge il token dallo storage (evita dipendenza circolare con lo store)
const token = localStorage.getItem('access_token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
// ─── Response Interceptor: gestisce 401 con refresh silenzioso ───────────────
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
}
// Salta il refresh per l'endpoint di login e refresh stesso
const skipRefresh = ['/auth/login', '/auth/refresh'].some((path) =>
originalRequest.url?.includes(path),
)
if (error.response?.status === 401 && !originalRequest._retry && !skipRefresh) {
originalRequest._retry = true
if (isRefreshing) {
// Accoda la richiesta in attesa del nuovo token
return new Promise((resolve) => {
pendingRequests.push((token: string) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`
}
resolve(apiClient(originalRequest))
})
})
}
isRefreshing = true
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) {
// Nessun refresh token: logout
handleLogout()
return Promise.reject(error)
}
try {
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
refresh_token: refreshToken,
})
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token
localStorage.setItem('access_token', newAccessToken)
localStorage.setItem('refresh_token', newRefreshToken)
processPendingRequests(newAccessToken)
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
}
return apiClient(originalRequest)
} catch {
handleLogout()
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
},
)
function handleLogout() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
// Redirect alla pagina di login
window.location.href = '/login'
}
/**
* Estrae il messaggio di errore leggibile da un AxiosError.
*/
export function getErrorMessage(error: unknown): string {
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail.map((d) => d.msg || d).join(', ')
}
if (error.response?.status === 404) return 'Risorsa non trovata'
if (error.response?.status === 403) return 'Accesso non autorizzato'
if (error.response?.status === 500) return 'Errore interno del server'
return error.message || 'Errore di rete'
}
if (error instanceof Error) return error.message
return 'Errore sconosciuto'
}
export default apiClient
+31
View File
@@ -0,0 +1,31 @@
import apiClient from './client'
import type {
ConnectionTestResult,
MailboxCreateRequest,
MailboxListResponse,
MailboxResponse,
MailboxUpdateRequest,
} from '@/types/api.types'
export const mailboxesApi = {
list: (page = 1, page_size = 50) =>
apiClient
.get<MailboxListResponse>('/mailboxes', { params: { page, page_size } })
.then((r) => r.data),
get: (id: string) =>
apiClient.get<MailboxResponse>(`/mailboxes/${id}`).then((r) => r.data),
create: (data: MailboxCreateRequest) =>
apiClient.post<MailboxResponse>('/mailboxes', data).then((r) => r.data),
update: (id: string, data: MailboxUpdateRequest) =>
apiClient.put<MailboxResponse>(`/mailboxes/${id}`, data).then((r) => r.data),
delete: (id: string) => apiClient.delete(`/mailboxes/${id}`),
testConnection: (id: string, protocol: 'imap' | 'smtp' = 'imap') =>
apiClient
.post<ConnectionTestResult>(`/mailboxes/${id}/test-connection`, { protocol })
.then((r) => r.data),
}
+53
View File
@@ -0,0 +1,53 @@
import apiClient from './client'
import type {
AttachmentResponse,
MessageListResponse,
MessageResponse,
} from '@/types/api.types'
export interface MessageFilters {
page?: number
page_size?: number
mailbox_id?: string
direction?: 'inbound' | 'outbound'
state?: string
is_read?: boolean
is_starred?: boolean
is_archived?: boolean
search?: string
}
export const messagesApi = {
list: (filters: MessageFilters = {}) =>
apiClient
.get<MessageListResponse>('/messages', { params: filters })
.then((r) => r.data),
get: (id: string) =>
apiClient.get<MessageResponse>(`/messages/${id}`).then((r) => r.data),
markRead: (id: string) =>
apiClient.patch<MessageResponse>(`/messages/${id}`, { is_read: true }).then((r) => r.data),
markUnread: (id: string) =>
apiClient.patch<MessageResponse>(`/messages/${id}`, { is_read: false }).then((r) => r.data),
toggleStar: (id: string, starred: boolean) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_starred: starred })
.then((r) => r.data),
archive: (id: string) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_archived: true })
.then((r) => r.data),
getAttachments: (id: string) =>
apiClient.get<AttachmentResponse[]>(`/messages/${id}/attachments`).then((r) => r.data),
getAttachmentUrl: (messageId: string, attachmentId: string) =>
`/api/v1/messages/${messageId}/attachments/${attachmentId}/download`,
getReceipts: (id: string) =>
apiClient.get<MessageResponse[]>(`/messages/${id}/receipts`).then((r) => r.data),
}
+50
View File
@@ -0,0 +1,50 @@
import apiClient from './client'
import type {
MailboxUserPermissionResponse,
PermissionGrantRequest,
PermissionResponse,
UserMailboxPermissionResponse,
} from '@/types/api.types'
export const permissionsApi = {
/**
* Assegna o aggiorna i permessi di un utente su una casella.
*/
grant: (mailboxId: string, userId: string, data: PermissionGrantRequest) =>
apiClient
.post<PermissionResponse>(
`/permissions/mailboxes/${mailboxId}/users/${userId}`,
data,
)
.then((r) => r.data),
/**
* Revoca i permessi di un utente su una casella.
*/
revoke: (mailboxId: string, userId: string) =>
apiClient.delete(`/permissions/mailboxes/${mailboxId}/users/${userId}`),
/**
* Lista utenti con accesso a una casella.
*/
listMailboxUsers: (mailboxId: string) =>
apiClient
.get<MailboxUserPermissionResponse[]>(`/permissions/mailboxes/${mailboxId}/users`)
.then((r) => r.data),
/**
* Lista caselle accessibili a un utente.
*/
listUserMailboxes: (userId: string) =>
apiClient
.get<UserMailboxPermissionResponse[]>(`/permissions/users/${userId}/mailboxes`)
.then((r) => r.data),
/**
* Caselle accessibili all'utente corrente.
*/
myMailboxes: () =>
apiClient
.get<UserMailboxPermissionResponse[]>('/permissions/my/mailboxes')
.then((r) => r.data),
}
+26
View File
@@ -0,0 +1,26 @@
import apiClient from './client'
import type {
SendJobListResponse,
SendJobResponse,
SendPecRequest,
} from '@/types/api.types'
export interface SendJobFilters {
page?: number
page_size?: number
mailbox_id?: string
status?: string
}
export const sendApi = {
send: (data: SendPecRequest) =>
apiClient.post<SendJobResponse>('/send', data).then((r) => r.data),
listJobs: (filters: SendJobFilters = {}) =>
apiClient.get<SendJobListResponse>('/send/jobs', { params: filters }).then((r) => r.data),
getJob: (id: string) =>
apiClient.get<SendJobResponse>(`/send/jobs/${id}`).then((r) => r.data),
cancelJob: (id: string) => apiClient.delete(`/send/jobs/${id}`),
}
+28
View File
@@ -0,0 +1,28 @@
import apiClient from './client'
import type {
UserCreateRequest,
UserListResponse,
UserResponse,
UserUpdateRequest,
} from '@/types/api.types'
export const usersApi = {
list: (page = 1, page_size = 25) =>
apiClient
.get<UserListResponse>('/users', { params: { page, page_size } })
.then((r) => r.data),
get: (id: string) =>
apiClient.get<UserResponse>(`/users/${id}`).then((r) => r.data),
create: (data: UserCreateRequest) =>
apiClient.post<UserResponse>('/users', data).then((r) => r.data),
update: (id: string, data: UserUpdateRequest) =>
apiClient.patch<UserResponse>(`/users/${id}`, data).then((r) => r.data),
delete: (id: string) => apiClient.delete(`/users/${id}`),
resetPassword: (id: string, new_password: string) =>
apiClient.post(`/users/${id}/reset-password`, { new_password }),
}
@@ -0,0 +1,64 @@
import { Outlet, Navigate } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { Sidebar } from './Sidebar'
import { useAuth } from '@/hooks/useAuth'
import { useWebSocket } from '@/hooks/useWebSocket'
import { useEffect } from 'react'
import { useAuthStore } from '@/store/auth.store'
/**
* Layout principale dell'applicazione autenticata.
* Include: sidebar + area contenuto + WebSocket + toast notifications.
*/
export function AppLayout() {
const { isAuthenticated, isLoading } = useAuth()
const loadUser = useAuthStore((s) => s.loadUser)
// Inizializza WebSocket (si connette automaticamente quando autenticato)
useWebSocket()
// Carica l'utente al mount se c'è un token
useEffect(() => {
loadUser()
}, [loadUser])
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Caricamento...</p>
</div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar navigazione */}
<Sidebar />
{/* Area contenuto principale */}
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
{/* Toast notifications globali */}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'hsl(var(--card))',
color: 'hsl(var(--card-foreground))',
border: '1px solid hsl(var(--border))',
},
}}
/>
</div>
)
}
+197
View File
@@ -0,0 +1,197 @@
import { NavLink } from 'react-router-dom'
import {
Inbox,
Send,
MailCheck,
Users,
Settings,
LogOut,
ChevronLeft,
ChevronRight,
Shield,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
import { useInboxStore } from '@/store/inbox.store'
import { useState } from 'react'
import toast from 'react-hot-toast'
interface NavItem {
to: string
label: string
icon: React.ElementType
adminOnly?: boolean
badge?: number
}
const NAV_ITEMS: NavItem[] = [
{ to: '/inbox', label: 'Posta in Arrivo', icon: Inbox },
{ to: '/sent', label: 'Posta Inviata', icon: Send },
{ to: '/compose', label: 'Nuova PEC', icon: MailCheck },
]
const ADMIN_NAV_ITEMS: NavItem[] = [
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck, adminOnly: true },
{ to: '/users', label: 'Utenti', icon: Users, adminOnly: true },
{ to: '/permissions', label: 'Permessi', icon: Shield, adminOnly: true },
]
export function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
const { user, isAdmin, logout } = useAuth()
const unreadCount = useInboxStore((s) => s.unreadCount)
const handleLogout = async () => {
try {
await logout()
toast.success('Disconnessione effettuata')
} catch {
toast.error('Errore durante il logout')
}
}
return (
<aside
className={cn(
'flex flex-col h-screen bg-gray-900 text-white transition-all duration-300',
collapsed ? 'w-16' : 'w-64',
)}
>
{/* Logo + toggle */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-blue-500 flex items-center justify-center text-white font-bold text-sm">
PF
</div>
<span className="font-bold text-lg">PecFlow</span>
</div>
)}
{collapsed && (
<div className="mx-auto h-8 w-8 rounded-lg bg-blue-500 flex items-center justify-center text-white font-bold text-sm">
PF
</div>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className={cn(
'p-1 rounded hover:bg-gray-700 transition-colors text-gray-400',
collapsed && 'mx-auto mt-0',
)}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div>
{/* Navigazione principale */}
<nav className="flex-1 overflow-y-auto py-4">
<div className="space-y-1 px-2">
{NAV_ITEMS.map((item) => (
<SidebarLink
key={item.to}
item={item}
collapsed={collapsed}
badge={item.to === '/inbox' ? unreadCount : undefined}
/>
))}
</div>
{/* Sezione Admin */}
{isAdmin && (
<>
<div className={cn('mt-6 px-4 mb-2', collapsed && 'hidden')}>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Amministrazione
</p>
</div>
{!collapsed && <div className="border-t border-gray-700 mx-4 mb-2" />}
<div className="space-y-1 px-2">
{ADMIN_NAV_ITEMS.map((item) => (
<SidebarLink key={item.to} item={item} collapsed={collapsed} />
))}
</div>
</>
)}
</nav>
{/* Profilo utente + logout */}
<div className="border-t border-gray-700 p-3">
{!collapsed ? (
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium flex-shrink-0">
{user?.full_name?.[0]?.toUpperCase() || 'U'}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user?.full_name}</p>
<p className="text-xs text-gray-400 truncate">{user?.email}</p>
</div>
</div>
<div className="flex gap-2">
<NavLink
to="/settings"
className="flex-1 flex items-center gap-2 px-2 py-1.5 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
>
<Settings className="h-3.5 w-3.5" />
Impostazioni
</NavLink>
<button
onClick={handleLogout}
className="flex-1 flex items-center gap-2 px-2 py-1.5 rounded text-xs text-gray-400 hover:text-red-400 hover:bg-gray-700 transition-colors"
>
<LogOut className="h-3.5 w-3.5" />
Esci
</button>
</div>
</div>
) : (
<button
onClick={handleLogout}
className="w-full flex justify-center p-2 rounded text-gray-400 hover:text-red-400 hover:bg-gray-700 transition-colors"
title="Esci"
>
<LogOut className="h-4 w-4" />
</button>
)}
</div>
</aside>
)
}
interface SidebarLinkProps {
item: NavItem
collapsed: boolean
badge?: number
}
function SidebarLink({ item, collapsed, badge }: SidebarLinkProps) {
const Icon = item.icon
return (
<NavLink
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? item.label : undefined}
>
<Icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className="flex-1">{item.label}</span>
{badge !== undefined && badge > 0 && (
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-blue-500 text-white text-xs font-bold">
{badge > 99 ? '99+' : badge}
</span>
)}
</>
)}
</NavLink>
)
}
@@ -0,0 +1,84 @@
import { cn, PEC_STATE_LABELS, PEC_TYPE_LABELS } from '@/lib/utils'
import type { PecMsgType, PecState } from '@/types/api.types'
interface PecBadgeProps {
state?: PecState
type?: PecMsgType
className?: string
}
const STATE_COLORS: Record<PecState, string> = {
draft: 'bg-gray-100 text-gray-700 border-gray-300',
queued: 'bg-yellow-100 text-yellow-800 border-yellow-300',
sent: 'bg-blue-100 text-blue-800 border-blue-300',
accepted: 'bg-cyan-100 text-cyan-800 border-cyan-300',
delivered: 'bg-green-100 text-green-800 border-green-300',
received: 'bg-green-100 text-green-800 border-green-300',
anomaly: 'bg-orange-100 text-orange-800 border-orange-300',
failed: 'bg-red-100 text-red-800 border-red-300',
}
const STATE_ICONS: Record<PecState, string> = {
draft: '📝',
queued: '⏳',
sent: '📤',
accepted: '✅',
delivered: '📬',
received: '📥',
anomaly: '⚠️',
failed: '❌',
}
const TYPE_COLORS: Record<string, string> = {
posta_certificata: 'bg-blue-50 text-blue-700 border-blue-200',
accettazione: 'bg-cyan-50 text-cyan-700 border-cyan-200',
non_accettazione: 'bg-orange-50 text-orange-700 border-orange-200',
avvenuta_consegna: 'bg-green-50 text-green-700 border-green-200',
mancata_consegna: 'bg-red-50 text-red-700 border-red-200',
errore_consegna: 'bg-red-50 text-red-700 border-red-200',
presa_in_carico: 'bg-purple-50 text-purple-700 border-purple-200',
preavviso_mancata_consegna: 'bg-amber-50 text-amber-700 border-amber-200',
rilevazione_virus: 'bg-red-50 text-red-700 border-red-200',
unknown: 'bg-gray-50 text-gray-700 border-gray-200',
}
/**
* Badge che mostra lo stato di una PEC con colore e icona.
*/
export function PecStateBadge({ state, className }: { state: PecState; className?: string }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',
STATE_COLORS[state] || 'bg-gray-100 text-gray-700 border-gray-300',
className,
)}
>
<span>{STATE_ICONS[state] || '?'}</span>
{PEC_STATE_LABELS[state] || state}
</span>
)
}
/**
* Badge che mostra il tipo di messaggio PEC.
*/
export function PecTypeBadge({ type, className }: { type: PecMsgType; className?: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
TYPE_COLORS[type] || 'bg-gray-50 text-gray-700 border-gray-200',
className,
)}
>
{PEC_TYPE_LABELS[type] || type}
</span>
)
}
export function PecBadge({ state, type, className }: PecBadgeProps) {
if (state) return <PecStateBadge state={state} className={className} />
if (type) return <PecTypeBadge type={type} className={className} />
return null
}
@@ -0,0 +1,110 @@
import { ChevronDown, ChevronRight, Mail } from 'lucide-react'
import { useState } from 'react'
import { PecTypeBadge, PecStateBadge } from '@/components/PecBadge/PecBadge'
import { formatDate } from '@/lib/utils'
import type { MessageResponse } from '@/types/api.types'
interface ReceiptTreeProps {
message: MessageResponse
receipts: MessageResponse[]
}
/**
* Visualizza la gerarchia delle ricevute PEC collegate a un messaggio.
* Mostra in ordine cronologico: accettazione → consegna (o anomalia).
*/
export function ReceiptTree({ message, receipts }: ReceiptTreeProps) {
const [expanded, setExpanded] = useState(true)
if (receipts.length === 0) {
if (message.direction === 'outbound') {
return (
<div className="rounded-lg border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
<Mail className="mb-1 inline h-4 w-4 mr-1" />
Nessuna ricevuta ancora ricevuta per questo messaggio.
</div>
)
}
return null
}
return (
<div className="space-y-2">
<button
className="flex items-center gap-2 text-sm font-medium text-foreground hover:text-primary"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Ricevute ({receipts.length})
</button>
{expanded && (
<div className="ml-4 space-y-2 border-l-2 border-muted pl-4">
{/* Messaggio originale */}
<ReceiptNode
label="Messaggio inviato"
date={message.sent_at || message.created_at}
state={message.state}
isRoot
/>
{/* Ricevute in ordine cronologico */}
{[...receipts]
.sort(
(a, b) =>
new Date(a.received_at || a.created_at).getTime() -
new Date(b.received_at || b.created_at).getTime(),
)
.map((receipt) => (
<ReceiptNode
key={receipt.id}
label={receipt.subject || 'Ricevuta'}
date={receipt.received_at || receipt.created_at}
type={receipt.pec_type}
messageId={receipt.id}
/>
))}
</div>
)}
</div>
)
}
interface ReceiptNodeProps {
label: string
date: string | null
state?: MessageResponse['state']
type?: MessageResponse['pec_type']
messageId?: string
isRoot?: boolean
}
function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
return (
<div className="flex items-start gap-3">
{/* Indicatore timeline */}
<div className="mt-1 flex flex-col items-center">
<div
className={`h-3 w-3 rounded-full border-2 ${
isRoot ? 'border-primary bg-primary/20' : 'border-muted-foreground bg-muted'
}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">{label}</span>
{state && !isRoot && <PecStateBadge state={state} />}
{type && type !== 'posta_certificata' && <PecTypeBadge type={type} />}
</div>
{date && (
<p className="text-xs text-muted-foreground mt-0.5">{formatDate(date)}</p>
)}
</div>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
success: 'border-transparent bg-green-100 text-green-800',
warning: 'border-transparent bg-yellow-100 text-yellow-800',
info: 'border-transparent bg-blue-100 text-blue-800',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+64
View File
@@ -0,0 +1,64 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, disabled, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+55
View File
@@ -0,0 +1,55 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
),
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+98
View File
@@ -0,0 +1,98 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Chiudi</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Input.displayName = 'Input'
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+147
View File
@@ -0,0 +1,147 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+27
View File
@@ -0,0 +1,27 @@
import { useAuthStore } from '@/store/auth.store'
/**
* Hook helper per accedere all'utente corrente e ai permessi.
*/
export function useAuth() {
const user = useAuthStore((s) => s.user)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const isLoading = useAuthStore((s) => s.isLoading)
const logout = useAuthStore((s) => s.logout)
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'
const isSuperAdmin = user?.role === 'super_admin'
const canSend = user?.role !== 'readonly'
const canManage = isAdmin
return {
user,
isAuthenticated,
isLoading,
isAdmin,
isSuperAdmin,
canSend,
canManage,
logout,
}
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Hook useWebSocket connessione WebSocket al backend PecFlow.
*
* Il backend usa FastAPI WebSocket nativo (non Socket.io).
* Endpoint: /api/v1/ws/{tenant_id}?token=<access_token>
*
* Gestisce:
* - Connessione automatica all'autenticazione
* - Riconnessione con backoff esponenziale
* - Dispatch degli eventi all'inbox/mailbox store
*/
import { useEffect, useRef, useCallback } from 'react'
import { useAuthStore } from '@/store/auth.store'
import { useInboxStore } from '@/store/inbox.store'
import { useMailboxStore } from '@/store/mailbox.store'
import type { MessageResponse, WsEvent } from '@/types/api.types'
import toast from 'react-hot-toast'
const MAX_RECONNECT_DELAY = 30000
const BASE_RECONNECT_DELAY = 1000
export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reconnectAttemptsRef = useRef(0)
const isUnmountedRef = useRef(false)
const { user, isAuthenticated } = useAuthStore()
const prependMessage = useInboxStore((s) => s.prependMessage)
const updateMailboxStatus = useMailboxStore((s) => s.updateMailboxStatus)
const handleEvent = useCallback(
(event: WsEvent) => {
switch (event.type) {
case 'mailbox:new_message': {
const message = event.payload as unknown as MessageResponse
prependMessage(message)
if (!message.is_read) {
const from = message.from_address || 'Mittente sconosciuto'
const subject = message.subject || '(nessun oggetto)'
toast.success(`📨 Nuova PEC da ${from}: ${subject}`, {
duration: 5000,
id: `new-msg-${message.id}`,
})
}
break
}
case 'mailbox:status_changed': {
const { mailbox_id, status, error_msg } = event.payload as {
mailbox_id: string
status: string
error_msg?: string
}
updateMailboxStatus(mailbox_id, status, error_msg)
if (status === 'error') {
toast.error(`⚠️ Errore sincronizzazione casella`, { duration: 8000 })
}
break
}
case 'send_job:status_changed': {
const { job_id: _jobId, status, mailbox_id: _mid } = event.payload as {
job_id: string
status: string
mailbox_id: string
}
if (status === 'sent') {
toast.success('✅ PEC inviata con successo', { duration: 4000 })
} else if (status === 'failed') {
toast.error('❌ Invio PEC fallito definitivamente', { duration: 8000 })
}
break
}
case 'send_job:anomaly': {
toast.error(
'⚠️ Anomalia invio PEC: nessuna ricevuta di accettazione entro 24h',
{ duration: 10000 },
)
break
}
}
},
[prependMessage, updateMailboxStatus],
)
const connect = useCallback(() => {
if (!isAuthenticated || !user) return
const token = localStorage.getItem('access_token')
if (!token) return
// Costruisce l'URL WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/api/v1/ws/${user.tenant_id}?token=${token}`
try {
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
console.log('[WS] Connessione stabilita')
reconnectAttemptsRef.current = 0
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as WsEvent
handleEvent(data)
} catch {
console.warn('[WS] Messaggio non valido:', event.data)
}
}
ws.onerror = (error) => {
console.error('[WS] Errore:', error)
}
ws.onclose = (event) => {
console.log('[WS] Connessione chiusa', event.code, event.reason)
wsRef.current = null
if (isUnmountedRef.current || !isAuthenticated) return
// Backoff esponenziale per la riconnessione
const delay = Math.min(
BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current),
MAX_RECONNECT_DELAY,
)
reconnectAttemptsRef.current += 1
console.log(`[WS] Riconnessione in ${delay}ms (tentativo ${reconnectAttemptsRef.current})`)
reconnectTimeoutRef.current = setTimeout(() => {
if (!isUnmountedRef.current) connect()
}, delay)
}
} catch (error) {
console.error('[WS] Errore durante la connessione:', error)
}
}, [isAuthenticated, user, handleEvent])
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
if (wsRef.current) {
wsRef.current.close(1000, 'Logout')
wsRef.current = null
}
}, [])
useEffect(() => {
isUnmountedRef.current = false
if (isAuthenticated && user) {
connect()
} else {
disconnect()
}
return () => {
isUnmountedRef.current = true
disconnect()
}
}, [isAuthenticated, user?.id, connect, disconnect])
return { isConnected: wsRef.current?.readyState === WebSocket.OPEN }
}
+94
View File
@@ -0,0 +1,94 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Scrollbar personalizzata */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-muted;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/50;
}
/* Animazione loader */
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Badge PEC stati */
.pec-badge-draft { @apply bg-gray-100 text-gray-700 border-gray-200; }
.pec-badge-queued { @apply bg-yellow-100 text-yellow-700 border-yellow-200; }
.pec-badge-sent { @apply bg-blue-100 text-blue-700 border-blue-200; }
.pec-badge-accepted { @apply bg-cyan-100 text-cyan-700 border-cyan-200; }
.pec-badge-delivered { @apply bg-green-100 text-green-700 border-green-200; }
.pec-badge-received { @apply bg-green-100 text-green-700 border-green-200; }
.pec-badge-anomaly { @apply bg-orange-100 text-orange-700 border-orange-200; }
.pec-badge-failed { @apply bg-red-100 text-red-700 border-red-200; }
.pec-badge-retrying { @apply bg-purple-100 text-purple-700 border-purple-200; }
+39
View File
@@ -0,0 +1,39 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
import './index.css'
// Configura React Query con retry intelligente
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30 secondi
retry: (failureCount, error) => {
// Non ritentare per 401/403/404
if (
typeof error === 'object' &&
error !== null &&
'response' in error
) {
const status = (error as { response?: { status?: number } }).response?.status
if (status === 401 || status === 403 || status === 404) return false
}
return failureCount < 2
},
},
mutations: {
retry: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>,
)
+296
View File
@@ -0,0 +1,296 @@
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useForm, useFieldArray } from 'react-hook-form'
import { Send, X, Plus, ArrowLeft, AlertCircle } from 'lucide-react'
import { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { sendApi } from '@/api/send.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { getErrorMessage } from '@/api/client'
import type { MessageResponse } from '@/types/api.types'
interface ComposeFormValues {
mailbox_id: string
to_addresses: { value: string }[]
cc_addresses: { value: string }[]
subject: string
body_text: string
}
export function ComposePage() {
const navigate = useNavigate()
const location = useLocation()
const replyTo = location.state?.replyTo as MessageResponse | undefined
const [showCc, setShowCc] = useState(false)
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<ComposeFormValues>({
defaultValues: {
mailbox_id: replyTo?.mailbox_id || '',
to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }],
cc_addresses: [],
subject: replyTo ? `Re: ${replyTo.subject || ''}` : '',
body_text: replyTo
? `\n\n---\nIn risposta al messaggio del ${new Date(replyTo.received_at || replyTo.created_at).toLocaleDateString('it-IT')}\nDa: ${replyTo.from_address || ''}\nA: ${replyTo.to_addresses?.join(', ') || ''}\nOggetto: ${replyTo.subject || ''}`
: '',
},
})
const {
fields: toFields,
append: appendTo,
remove: removeTo,
} = useFieldArray({ control, name: 'to_addresses' })
const {
fields: ccFields,
append: appendCc,
remove: removeCc,
} = useFieldArray({ control, name: 'cc_addresses' })
// Carica caselle disponibili per l'invio
const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
})
const sendMutation = useMutation({
mutationFn: sendApi.send,
onSuccess: (job) => {
toast.success(`PEC inviata! Job ID: ${job.id.slice(0, 8)}...`)
navigate('/sent')
},
onError: (error) => {
toast.error(getErrorMessage(error))
},
})
const onSubmit = async (data: ComposeFormValues) => {
const toAddresses = data.to_addresses
.map((t) => t.value.trim())
.filter((v) => v.length > 0)
if (toAddresses.length === 0) {
toast.error('Inserisci almeno un destinatario')
return
}
await sendMutation.mutateAsync({
mailbox_id: data.mailbox_id,
to_addresses: toAddresses,
cc_addresses: data.cc_addresses
.map((c) => c.value.trim())
.filter((v) => v.length > 0),
subject: data.subject,
body_text: data.body_text,
reply_to_message_id: replyTo?.id,
})
}
const activeCaselle = mailboxesData?.items.filter((m) => m.status === 'active') || []
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-lg font-semibold">
{replyTo ? 'Rispondi a PEC' : 'Nuova PEC'}
</h1>
{replyTo && (
<p className="text-xs text-muted-foreground">
In risposta a: {replyTo.subject}
</p>
)}
</div>
</div>
</div>
{/* Form */}
<div className="flex-1 overflow-y-auto">
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl mx-auto px-6 py-8 space-y-6">
{/* Avviso */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-700">
L'invio avviene tramite il server PEC della casella selezionata.
La ricevuta di accettazione arriverà entro pochi minuti.
</p>
</div>
{/* Casella mittente */}
<div className="space-y-2">
<Label htmlFor="mailbox_id">Casella mittente *</Label>
{mailboxesLoading ? (
<div className="h-10 rounded-md border bg-muted animate-pulse" />
) : activeCaselle.length === 0 ? (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
Nessuna casella PEC attiva disponibile. Contatta l'amministratore.
</div>
) : (
<select
id="mailbox_id"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
{...register('mailbox_id', { required: 'Seleziona una casella mittente' })}
>
<option value="">Seleziona casella...</option>
{activeCaselle.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.display_name || mb.email_address} ({mb.email_address})
</option>
))}
</select>
)}
{errors.mailbox_id && (
<p className="text-xs text-destructive">{errors.mailbox_id.message}</p>
)}
</div>
{/* Destinatari A: */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Destinatari (A:) *</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => appendTo({ value: '' })}
>
<Plus className="h-4 w-4 mr-1" />
Aggiungi
</Button>
</div>
<div className="space-y-2">
{toFields.map((field, idx) => (
<div key={field.id} className="flex gap-2">
<Input
type="email"
placeholder="destinatario@pec.it"
{...register(`to_addresses.${idx}.value`, {
required: idx === 0 ? 'Almeno un destinatario è obbligatorio' : false,
})}
/>
{toFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeTo(idx)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
{errors.to_addresses?.[0]?.value && (
<p className="text-xs text-destructive">
{errors.to_addresses[0].value.message}
</p>
)}
</div>
{/* CC */}
<div className="space-y-2">
{!showCc ? (
<button
type="button"
onClick={() => setShowCc(true)}
className="text-sm text-primary hover:underline"
>
+ Aggiungi Cc
</button>
) : (
<>
<div className="flex items-center justify-between">
<Label>Copia (Cc:)</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => appendCc({ value: '' })}
>
<Plus className="h-4 w-4 mr-1" />
Aggiungi
</Button>
</div>
{ccFields.map((field, idx) => (
<div key={field.id} className="flex gap-2">
<Input
type="email"
placeholder="cc@pec.it"
{...register(`cc_addresses.${idx}.value`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeCc(idx)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</>
)}
</div>
{/* Oggetto */}
<div className="space-y-2">
<Label htmlFor="subject">Oggetto *</Label>
<Input
id="subject"
placeholder="Oggetto della PEC"
{...register('subject', { required: "L'oggetto è obbligatorio" })}
/>
{errors.subject && (
<p className="text-xs text-destructive">{errors.subject.message}</p>
)}
</div>
{/* Corpo */}
<div className="space-y-2">
<Label htmlFor="body_text">Testo del messaggio</Label>
<textarea
id="body_text"
rows={12}
placeholder="Testo della PEC..."
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
{...register('body_text')}
/>
</div>
{/* Azioni */}
<div className="flex items-center justify-between pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
>
Annulla
</Button>
<Button
type="submit"
isLoading={sendMutation.isPending}
disabled={activeCaselle.length === 0}
>
<Send className="h-4 w-4 mr-2" />
Invia PEC
</Button>
</div>
</form>
</div>
</div>
)
}
+363
View File
@@ -0,0 +1,363 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Inbox,
RefreshCw,
Search,
Star,
Mail,
MailOpen,
Filter,
Send,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { messagesApi } from '@/api/messages.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { useInboxStore } from '@/store/inbox.store'
import { formatRelative, truncate } from '@/lib/utils'
import type { MessageResponse } from '@/types/api.types'
import { cn } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
export function InboxPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { filters, setFilters } = useInboxStore()
const [searchInput, setSearchInput] = useState('')
const [selectedDirection, setSelectedDirection] = useState<'all' | 'inbound' | 'outbound'>('all')
// Carica caselle per il filtro
const { data: mailboxesData } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
})
// Carica messaggi
const {
data: messagesData,
isLoading,
refetch,
isRefetching,
} = useQuery({
queryKey: ['messages', filters],
queryFn: () =>
messagesApi.list({
...filters,
direction: selectedDirection === 'all' ? undefined : selectedDirection,
search: searchInput || undefined,
}),
refetchInterval: 60000, // refresh ogni minuto
})
// Segna come letto
const markReadMutation = useMutation({
mutationFn: messagesApi.markRead,
onSuccess: (updatedMsg) => {
queryClient.setQueryData(['messages', filters], (old: { items: MessageResponse[] } | undefined) => {
if (!old) return old
return {
...old,
items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)),
}
})
},
})
// Gestione ricerca con debounce
useEffect(() => {
const timer = setTimeout(() => {
setFilters({ search: searchInput || undefined, page: 1 })
}, 400)
return () => clearTimeout(timer)
}, [searchInput, setFilters])
const handleMessageClick = async (message: MessageResponse) => {
if (!message.is_read) {
markReadMutation.mutate(message.id)
}
navigate(`/messages/${message.id}`)
}
const handleRefresh = async () => {
try {
await refetch()
toast.success('Casella aggiornata')
} catch (error) {
toast.error(getErrorMessage(error))
}
}
const messages = messagesData?.items || []
const total = messagesData?.total || 0
const currentPage = filters.page || 1
const pageSize = filters.page_size || 50
const totalPages = Math.ceil(total / pageSize)
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Inbox className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Posta</h1>
{total > 0 && (
<span className="text-sm text-muted-foreground">({total} messaggi)</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
isLoading={isRefetching}
>
<RefreshCw className="h-4 w-4 mr-1" />
Aggiorna
</Button>
<Button size="sm" onClick={() => navigate('/compose')}>
<Send className="h-4 w-4 mr-1" />
Nuova PEC
</Button>
</div>
</div>
{/* Filtri */}
<div className="flex items-center gap-3 flex-wrap">
{/* Barra di ricerca */}
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca per oggetto, mittente..."
className="pl-9 h-9"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
{/* Filtro direzione */}
<div className="flex gap-1 p-1 rounded-lg bg-muted">
{(['all', 'inbound', 'outbound'] as const).map((d) => (
<button
key={d}
onClick={() => {
setSelectedDirection(d)
setFilters({ direction: d === 'all' ? undefined : d, page: 1 })
}}
className={cn(
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
selectedDirection === d
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{d === 'all' ? 'Tutti' : d === 'inbound' ? '📥 In arrivo' : '📤 Inviati'}
</button>
))}
</div>
{/* Filtro casella */}
{mailboxesData?.items && mailboxesData.items.length > 1 && (
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={filters.mailbox_id || ''}
onChange={(e) => setFilters({ mailbox_id: e.target.value || undefined, page: 1 })}
>
<option value="">Tutte le caselle</option>
{mailboxesData.items.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.display_name || mb.email_address}
</option>
))}
</select>
)}
{/* Filtro letti/non letti */}
<div className="flex gap-1">
<Button
variant={filters.is_read === false ? 'default' : 'outline'}
size="sm"
onClick={() =>
setFilters({ is_read: filters.is_read === false ? undefined : false })
}
className="h-9 text-xs"
>
<Mail className="h-3.5 w-3.5 mr-1" />
Non letti
</Button>
<Button
variant={filters.is_starred === true ? 'default' : 'outline'}
size="sm"
onClick={() =>
setFilters({ is_starred: filters.is_starred === true ? undefined : true })
}
className="h-9 text-xs"
>
<Star className="h-3.5 w-3.5 mr-1" />
Preferiti
</Button>
</div>
</div>
</div>
{/* Lista messaggi */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Caricamento messaggi...</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Inbox className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Nessun messaggio trovato</p>
<p className="text-sm text-muted-foreground/70 mt-1">
{searchInput ? 'Prova a modificare i filtri di ricerca' : 'La casella è vuota'}
</p>
</div>
) : (
<div className="divide-y">
{messages.map((message) => (
<MessageRow
key={message.id}
message={message}
onClick={() => handleMessageClick(message)}
mailboxName={
mailboxesData?.items.find((m) => m.id === message.mailbox_id)
?.email_address
}
/>
))}
</div>
)}
</div>
{/* Paginazione */}
{totalPages > 1 && (
<div className="border-t px-6 py-3 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Pagina {currentPage} di {totalPages} ({total} messaggi)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setFilters({ page: currentPage - 1 })}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setFilters({ page: currentPage + 1 })}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}
// ─── Riga singolo messaggio ────────────────────────────────────────────────────
interface MessageRowProps {
message: MessageResponse
onClick: () => void
mailboxName?: string
}
function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
const isUnread = !message.is_read && message.direction === 'inbound'
return (
<div
className={cn(
'flex items-start gap-3 px-6 py-4 cursor-pointer hover:bg-muted/50 transition-colors',
isUnread && 'bg-blue-50/50',
)}
onClick={onClick}
>
{/* Icona direzione */}
<div className="mt-1 flex-shrink-0">
{message.direction === 'inbound' ? (
isUnread ? (
<Mail className="h-5 w-5 text-blue-600" />
) : (
<MailOpen className="h-5 w-5 text-muted-foreground" />
)
) : (
<Send className="h-5 w-5 text-muted-foreground" />
)}
</div>
{/* Contenuto */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={cn(
'text-sm truncate',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground',
)}
>
{message.direction === 'inbound'
? message.from_address || 'Mittente sconosciuto'
: (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
</span>
{mailboxName && (
<span className="text-xs text-muted-foreground flex-shrink-0">
{mailboxName}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<PecStateBadge state={message.state} />
<span className="text-xs text-muted-foreground">
{formatRelative(message.received_at || message.sent_at || message.created_at)}
</span>
</div>
</div>
<div className="flex items-center gap-2 mt-0.5">
{isUnread && (
<span className="h-2 w-2 rounded-full bg-blue-600 flex-shrink-0" />
)}
<p
className={cn(
'text-sm truncate',
isUnread ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
{message.subject || '(nessun oggetto)'}
</p>
</div>
{message.body_text && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{truncate(message.body_text, 120)}
</p>
)}
</div>
{/* Indicatori */}
<div className="flex flex-col items-center gap-1 flex-shrink-0">
{message.is_starred && <Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />}
{message.has_attachments && (
<span className="text-xs text-muted-foreground">📎</span>
)}
</div>
</div>
)
}
+196
View File
@@ -0,0 +1,196 @@
import { useState } from 'react'
import { useNavigate, Navigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { Mail, Lock, Eye, EyeOff, Shield } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
import { useAuthStore } from '@/store/auth.store'
import { getErrorMessage } from '@/api/client'
interface LoginFormValues {
email: string
password: string
totp_code?: string
}
type LoginStep = 'credentials' | 'totp'
export function LoginPage() {
const navigate = useNavigate()
const { isAuthenticated, login, isLoading } = useAuthStore()
const [step, setStep] = useState<LoginStep>('credentials')
const [showPassword, setShowPassword] = useState(false)
const {
register,
handleSubmit,
getValues,
formState: { errors },
} = useForm<LoginFormValues>()
// Se già autenticato, vai all'inbox
if (isAuthenticated) {
return <Navigate to="/inbox" replace />
}
const onSubmit = async (data: LoginFormValues) => {
try {
await login({
email: data.email,
password: data.password,
totp_code: data.totp_code,
})
toast.success('Accesso effettuato con successo')
navigate('/inbox', { replace: true })
} catch (error) {
const msg = getErrorMessage(error)
// Se l'errore indica che serve il TOTP, passa al secondo step
if (msg.toLowerCase().includes('totp') || msg.toLowerCase().includes('otp') || msg.toLowerCase().includes('2fa')) {
setStep('totp')
return
}
toast.error(msg)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-6">
{/* Logo */}
<div className="text-center">
<div className="mx-auto h-16 w-16 rounded-2xl bg-blue-600 flex items-center justify-center shadow-lg">
<span className="text-white font-bold text-2xl">PF</span>
</div>
<h1 className="mt-4 text-3xl font-bold text-gray-900">PecFlow</h1>
<p className="mt-1 text-sm text-gray-500">Gestore PEC SaaS</p>
</div>
<Card className="shadow-sm">
{step === 'credentials' ? (
<>
<CardHeader>
<CardTitle className="text-xl">Accedi</CardTitle>
<CardDescription>Inserisci le tue credenziali per continuare</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Indirizzo email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="nome@azienda.it"
className="pl-10"
autoComplete="email"
{...register('email', {
required: 'Email obbligatoria',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Formato email non valido',
},
})}
/>
</div>
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
className="pl-10 pr-10"
autoComplete="current-password"
{...register('password', { required: 'Password obbligatoria' })}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{errors.password && (
<p className="text-xs text-destructive">{errors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full" isLoading={isLoading}>
Accedi
</Button>
</form>
</CardContent>
</>
) : (
<>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-600" />
<CardTitle className="text-xl">Verifica 2FA</CardTitle>
</div>
<CardDescription>
Inserisci il codice a 6 cifre dall'app autenticatore per{' '}
<strong>{getValues('email')}</strong>
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="totp_code">Codice OTP</Label>
<Input
id="totp_code"
type="text"
placeholder="000000"
maxLength={6}
className="text-center text-2xl tracking-widest font-mono"
autoComplete="one-time-code"
autoFocus
{...register('totp_code', {
required: 'Codice OTP obbligatorio',
minLength: { value: 6, message: 'Il codice deve avere 6 cifre' },
maxLength: { value: 6, message: 'Il codice deve avere 6 cifre' },
pattern: { value: /^\d{6}$/, message: 'Solo cifre (6 caratteri)' },
})}
/>
{errors.totp_code && (
<p className="text-xs text-destructive">{errors.totp_code.message}</p>
)}
</div>
<Button type="submit" className="w-full" isLoading={isLoading}>
Verifica
</Button>
<button
type="button"
onClick={() => setStep('credentials')}
className="w-full text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Torna alle credenziali
</button>
</form>
</CardContent>
</>
)}
</Card>
<p className="text-center text-xs text-gray-400">
PecFlow v1.0 Piattaforma gestione PEC certificata
</p>
</div>
</div>
)
}
@@ -0,0 +1,397 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Plus,
MailCheck,
Trash2,
Edit,
TestTube,
AlertCircle,
CheckCircle,
Clock,
RefreshCw,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { mailboxesApi } from '@/api/mailboxes.api'
import { getErrorMessage } from '@/api/client'
import { formatDate, MAILBOX_STATUS_LABELS } from '@/lib/utils'
import { cn } from '@/lib/utils'
import type { MailboxCreateRequest, MailboxResponse } from '@/types/api.types'
const STATUS_COLORS = {
active: 'text-green-700 bg-green-100',
paused: 'text-yellow-700 bg-yellow-100',
error: 'text-red-700 bg-red-100',
deleted: 'text-gray-700 bg-gray-100',
}
const STATUS_ICONS = {
active: CheckCircle,
paused: Clock,
error: AlertCircle,
deleted: Trash2,
}
export function MailboxesPage() {
const queryClient = useQueryClient()
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [editingMailbox, setEditingMailbox] = useState<MailboxResponse | null>(null)
const [testingId, setTestingId] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
const { data: mailboxesData, isLoading } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(1, 200),
})
const deleteMutation = useMutation({
mutationFn: mailboxesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mailboxes'] })
toast.success('Casella eliminata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const handleTest = async (mailbox: MailboxResponse) => {
setTestingId(mailbox.id)
setTestResult(null)
try {
const result = await mailboxesApi.testConnection(mailbox.id, 'imap')
setTestResult(result)
toast[result.success ? 'success' : 'error'](result.message)
} catch (e) {
toast.error(getErrorMessage(e))
} finally {
setTestingId(null)
}
}
const handleDelete = async (mailbox: MailboxResponse) => {
if (!confirm(`Eliminare la casella ${mailbox.email_address}? L'operazione è irreversibile.`)) return
deleteMutation.mutate(mailbox.id)
}
const mailboxes = mailboxesData?.items || []
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<MailCheck className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Caselle PEC</h1>
<span className="text-sm text-muted-foreground">({mailboxes.length})</span>
</div>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Aggiungi casella
</Button>
</div>
{/* Contenuto */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : mailboxes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<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-sm text-muted-foreground/70 mt-1">
Aggiungi una casella per iniziare a gestire le PEC
</p>
<Button className="mt-4" onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Aggiungi casella
</Button>
</div>
) : (
<div className="grid gap-4">
{mailboxes.map((mailbox) => {
const StatusIcon = STATUS_ICONS[mailbox.status] || AlertCircle
return (
<div
key={mailbox.id}
className="rounded-lg border bg-card p-5 flex items-start justify-between gap-4"
>
<div className="flex items-start gap-4 min-w-0">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<MailCheck className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-foreground">
{mailbox.display_name || mailbox.email_address}
</h3>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
STATUS_COLORS[mailbox.status],
)}
>
<StatusIcon className="h-3 w-3" />
{MAILBOX_STATUS_LABELS[mailbox.status]}
</span>
{mailbox.provider && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
{mailbox.provider}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">{mailbox.email_address}</p>
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
<span>IMAP: {mailbox.imap_host}:{mailbox.imap_port}</span>
<span>SMTP: {mailbox.smtp_host}:{mailbox.smtp_port}</span>
{mailbox.last_sync_at && (
<span>Ultima sync: {formatDate(mailbox.last_sync_at)}</span>
)}
</div>
{mailbox.status === 'error' && mailbox.sync_error_msg && (
<p className="text-xs text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{mailbox.sync_error_msg}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Test connessione */}
<Button
variant="outline"
size="sm"
onClick={() => handleTest(mailbox)}
isLoading={testingId === mailbox.id}
title="Testa connessione IMAP"
>
<TestTube className="h-4 w-4 mr-1" />
Test
</Button>
{/* Modifica */}
<Button
variant="outline"
size="sm"
onClick={() => setEditingMailbox(mailbox)}
>
<Edit className="h-4 w-4" />
</Button>
{/* Elimina */}
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(mailbox)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Dialog crea/modifica casella */}
<MailboxFormDialog
open={showCreateDialog || editingMailbox !== null}
onClose={() => {
setShowCreateDialog(false)
setEditingMailbox(null)
}}
editingMailbox={editingMailbox}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['mailboxes'] })
setShowCreateDialog(false)
setEditingMailbox(null)
}}
/>
</div>
)
}
// ─── Dialog creazione/modifica casella ───────────────────────────────────────
interface MailboxFormDialogProps {
open: boolean
onClose: () => void
editingMailbox: MailboxResponse | null
onSaved: () => void
}
function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFormDialogProps) {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<MailboxCreateRequest>({
defaultValues: editingMailbox
? {
email_address: editingMailbox.email_address,
display_name: editingMailbox.display_name || '',
provider: editingMailbox.provider || '',
imap_host: editingMailbox.imap_host,
imap_port: editingMailbox.imap_port,
imap_user: editingMailbox.email_address,
imap_use_ssl: editingMailbox.imap_use_ssl,
smtp_host: editingMailbox.smtp_host,
smtp_port: editingMailbox.smtp_port,
smtp_user: editingMailbox.email_address,
smtp_use_tls: editingMailbox.smtp_use_tls,
}
: {
imap_port: 993,
smtp_port: 465,
imap_use_ssl: true,
smtp_use_tls: true,
},
})
const createMutation = useMutation({
mutationFn: mailboxesApi.create,
onSuccess: () => {
toast.success('Casella creata con successo')
onSaved()
reset()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: MailboxCreateRequest }) =>
mailboxesApi.update(id, data),
onSuccess: () => {
toast.success('Casella aggiornata')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const onSubmit = (data: MailboxCreateRequest) => {
if (editingMailbox) {
updateMutation.mutate({ id: editingMailbox.id, data })
} else {
createMutation.mutate(data)
}
}
const isLoading = createMutation.isPending || updateMutation.isPending
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingMailbox ? 'Modifica casella PEC' : 'Aggiungi casella PEC'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
{/* Info generali */}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-2">
<Label>Indirizzo PEC *</Label>
<Input
type="email"
placeholder="casella@pec.it"
disabled={!!editingMailbox}
{...register('email_address', { required: 'Obbligatorio' })}
/>
</div>
<div className="space-y-2">
<Label>Nome visualizzato</Label>
<Input placeholder="Es: Casella principale" {...register('display_name')} />
</div>
<div className="space-y-2">
<Label>Provider</Label>
<Input placeholder="aruba, namirial..." {...register('provider')} />
</div>
</div>
{/* Separatore IMAP */}
<div className="space-y-3 rounded-lg border p-4">
<h4 className="text-sm font-semibold">Configurazione IMAP (ricezione)</h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Server IMAP *</Label>
<Input placeholder="imap.pec.it" {...register('imap_host', { required: true })} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Porta</Label>
<Input type="number" {...register('imap_port', { valueAsNumber: true })} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Utente IMAP *</Label>
<Input placeholder="casella@pec.it" {...register('imap_user', { required: true })} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Password IMAP *</Label>
<Input
type="password"
placeholder={editingMailbox ? '(invariata)' : ''}
{...register('imap_pass', {
required: !editingMailbox ? 'Obbligatoria' : false,
})}
/>
</div>
</div>
</div>
{/* Separatore SMTP */}
<div className="space-y-3 rounded-lg border p-4">
<h4 className="text-sm font-semibold">Configurazione SMTP (invio)</h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Server SMTP *</Label>
<Input placeholder="smtp.pec.it" {...register('smtp_host', { required: true })} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Porta</Label>
<Input type="number" {...register('smtp_port', { valueAsNumber: true })} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Utente SMTP *</Label>
<Input placeholder="casella@pec.it" {...register('smtp_user', { required: true })} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Password SMTP *</Label>
<Input
type="password"
placeholder={editingMailbox ? '(invariata)' : ''}
{...register('smtp_pass', {
required: !editingMailbox ? 'Obbligatoria' : false,
})}
/>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Annulla
</Button>
<Button type="submit" isLoading={isLoading}>
{editingMailbox ? 'Salva modifiche' : 'Crea casella'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,296 @@
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
ArrowLeft,
Star,
Archive,
Download,
Reply,
Forward,
Paperclip,
Mail,
Send,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
import { messagesApi } from '@/api/messages.api'
import { formatDate, formatBytes, MAILBOX_STATUS_LABELS } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
export function MessageDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
// Carica messaggio
const {
data: message,
isLoading,
error,
} = useQuery({
queryKey: ['message', id],
queryFn: () => messagesApi.get(id!),
enabled: !!id,
})
// Carica allegati
const { data: attachments = [] } = useQuery({
queryKey: ['attachments', id],
queryFn: () => messagesApi.getAttachments(id!),
enabled: !!id && !!message?.has_attachments,
})
// Carica ricevute (solo per messaggi outbound)
const { data: receipts = [] } = useQuery({
queryKey: ['receipts', id],
queryFn: () => messagesApi.getReceipts(id!),
enabled: !!id && message?.direction === 'outbound',
})
// Toggle stella
const toggleStarMutation = useMutation({
mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred),
onSuccess: (updated) => {
queryClient.setQueryData(['message', id], updated)
toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// Archivia
const archiveMutation = useMutation({
mutationFn: () => messagesApi.archive(id!),
onSuccess: () => {
toast.success('Messaggio archiviato')
navigate('/inbox')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (error || !message) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<p className="text-muted-foreground">Messaggio non trovato</p>
<Button variant="outline" onClick={() => navigate('/inbox')}>
<ArrowLeft className="h-4 w-4 mr-2" />
Torna alla posta
</Button>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="border-b bg-background px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<span className="text-sm text-muted-foreground">
{message.direction === 'inbound' ? 'Posta in arrivo' : 'Posta inviata'}
</span>
</div>
<div className="flex items-center gap-2">
{/* Stella */}
<Button
variant="ghost"
size="icon"
onClick={() => toggleStarMutation.mutate(!message.is_starred)}
>
<Star
className={`h-5 w-5 ${message.is_starred ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground'}`}
/>
</Button>
{/* Archivia */}
{!message.is_archived && (
<Button
variant="ghost"
size="icon"
onClick={() => archiveMutation.mutate()}
title="Archivia"
>
<Archive className="h-5 w-5 text-muted-foreground" />
</Button>
)}
{/* Rispondi (solo per messaggi inbound PEC certificata) */}
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && (
<Button
variant="outline"
size="sm"
onClick={() =>
navigate('/compose', {
state: { replyTo: message },
})
}
>
<Reply className="h-4 w-4 mr-1" />
Rispondi
</Button>
)}
</div>
</div>
{/* Contenuto */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-8 space-y-6">
{/* Intestazione messaggio */}
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-semibold text-foreground leading-tight">
{message.subject || '(nessun oggetto)'}
</h1>
<div className="flex items-center gap-2 flex-shrink-0">
<PecStateBadge state={message.state} />
{message.pec_type !== 'posta_certificata' && (
<PecTypeBadge type={message.pec_type} />
)}
</div>
</div>
{/* Dettagli busta */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
<div className="grid grid-cols-[auto,1fr] gap-x-4 gap-y-1.5 text-sm">
<span className="font-medium text-muted-foreground">
{message.direction === 'inbound' ? 'Da:' : 'A:'}
</span>
<span className="text-foreground font-medium">
{message.direction === 'inbound'
? message.from_address
: message.to_addresses?.join(', ')}
</span>
{message.direction === 'outbound' && message.from_address && (
<>
<span className="font-medium text-muted-foreground">Da:</span>
<span>{message.from_address}</span>
</>
)}
{message.direction === 'inbound' && message.to_addresses?.length > 0 && (
<>
<span className="font-medium text-muted-foreground">A:</span>
<span>{message.to_addresses.join(', ')}</span>
</>
)}
{message.cc_addresses?.length > 0 && (
<>
<span className="font-medium text-muted-foreground">Cc:</span>
<span>{message.cc_addresses.join(', ')}</span>
</>
)}
<span className="font-medium text-muted-foreground">Data:</span>
<span>{formatDate(message.received_at || message.sent_at || message.created_at)}</span>
{message.size_bytes && (
<>
<span className="font-medium text-muted-foreground">Dimensione:</span>
<span>{formatBytes(message.size_bytes)}</span>
</>
)}
<span className="font-medium text-muted-foreground">Cartella:</span>
<span className="font-mono text-xs">{message.imap_folder}</span>
</div>
</div>
</div>
{/* Corpo del messaggio */}
{(message.body_html || message.body_text) && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Contenuto
</h3>
<div className="rounded-lg border bg-background p-6">
{message.body_html ? (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: message.body_html }}
/>
) : (
<pre className="whitespace-pre-wrap text-sm font-sans text-foreground">
{message.body_text}
</pre>
)}
</div>
</div>
)}
{/* Allegati */}
{attachments.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Paperclip className="h-4 w-4" />
Allegati ({attachments.length})
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{attachments.map((att) => (
<a
key={att.id}
href={messagesApi.getAttachmentUrl(message.id, att.id)}
download={att.filename}
className="flex items-center gap-3 rounded-lg border bg-background p-3 hover:bg-muted/50 transition-colors group"
>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Paperclip className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{att.filename}</p>
<p className="text-xs text-muted-foreground">
{att.content_type || 'Tipo sconosciuto'} {formatBytes(att.size_bytes)}
</p>
</div>
<Download className="h-4 w-4 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
</a>
))}
</div>
</div>
)}
{/* Ricevute (solo per outbound) */}
{message.direction === 'outbound' && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Send className="h-4 w-4" />
Tracciamento invio
</h3>
<div className="rounded-lg border bg-background p-4">
<ReceiptTree message={message} receipts={receipts} />
</div>
</div>
)}
{/* Messaggio originale per ricevute */}
{message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-center gap-2 text-sm text-blue-700">
<Mail className="h-4 w-4" />
<span>
Questo è un messaggio automatico di tipo{' '}
<strong>
<PecTypeBadge type={message.pec_type} />
</strong>
</span>
</div>
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,348 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Shield, Plus, Trash2, MailCheck, User } from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
import { permissionsApi } from '@/api/permissions.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { usersApi } from '@/api/users.api'
import { getErrorMessage } from '@/api/client'
import { cn, ROLE_LABELS } from '@/lib/utils'
import type { MailboxUserPermissionResponse } from '@/types/api.types'
export function PermissionsPage() {
const queryClient = useQueryClient()
const [selectedMailboxId, setSelectedMailboxId] = useState<string>('')
const [showGrantDialog, setShowGrantDialog] = useState(false)
const { data: mailboxesData } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(1, 200),
})
const { data: mailboxUsers = [], isLoading: loadingUsers } = useQuery({
queryKey: ['mailbox-permissions', selectedMailboxId],
queryFn: () => permissionsApi.listMailboxUsers(selectedMailboxId),
enabled: !!selectedMailboxId,
})
const revokeMutation = useMutation({
mutationFn: ({ userId }: { userId: string }) =>
permissionsApi.revoke(selectedMailboxId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mailbox-permissions', selectedMailboxId] })
toast.success('Permesso revocato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const mailboxes = mailboxesData?.items || []
const selectedMailbox = mailboxes.find((m) => m.id === selectedMailboxId)
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Permessi Caselle</h1>
</div>
{selectedMailboxId && (
<Button onClick={() => setShowGrantDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Assegna permesso
</Button>
)}
</div>
<div className="flex flex-1 overflow-hidden">
{/* Pannello sinistra: lista caselle */}
<div className="w-64 border-r flex flex-col">
<div className="p-3 border-b">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Caselle PEC
</p>
</div>
<div className="flex-1 overflow-y-auto py-2">
{mailboxes.map((mb) => (
<button
key={mb.id}
onClick={() => setSelectedMailboxId(mb.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/50 transition-colors',
selectedMailboxId === mb.id && 'bg-primary/10 text-primary font-medium',
)}
>
<MailCheck className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm truncate">
{mb.display_name || mb.email_address}
</p>
<p className="text-xs text-muted-foreground truncate">{mb.email_address}</p>
</div>
</button>
))}
</div>
</div>
{/* Pannello destra: utenti con accesso */}
<div className="flex-1 overflow-y-auto p-6">
{!selectedMailboxId ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Shield className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">Seleziona una casella per gestire i permessi</p>
</div>
) : loadingUsers ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold">
Accesso a: {selectedMailbox?.display_name || selectedMailbox?.email_address}
</h2>
<p className="text-sm text-muted-foreground">
{mailboxUsers.length} utenti con accesso
</p>
</div>
{mailboxUsers.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center">
<User className="h-10 w-10 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-muted-foreground text-sm">
Nessun utente con accesso esplicito.
Gli admin del tenant hanno accesso automatico.
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => setShowGrantDialog(true)}
>
<Plus className="h-4 w-4 mr-1" />
Assegna permesso
</Button>
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Utente</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Ruolo</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Lettura</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Invio</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Gestione</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Azioni</th>
</tr>
</thead>
<tbody className="divide-y">
{mailboxUsers.map((perm) => (
<PermissionRow
key={perm.user_id}
perm={perm}
mailboxId={selectedMailboxId}
onRevoke={() =>
revokeMutation.mutate({ userId: perm.user_id })
}
onUpdate={() =>
queryClient.invalidateQueries({
queryKey: ['mailbox-permissions', selectedMailboxId],
})
}
/>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</div>
{/* Dialog assegna permesso */}
{showGrantDialog && selectedMailboxId && (
<GrantPermissionDialog
mailboxId={selectedMailboxId}
onClose={() => setShowGrantDialog(false)}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['mailbox-permissions', selectedMailboxId] })
setShowGrantDialog(false)
}}
/>
)}
</div>
)
}
// ─── Riga permesso ────────────────────────────────────────────────────────────
interface PermissionRowProps {
perm: MailboxUserPermissionResponse
mailboxId: string
onRevoke: () => void
onUpdate: () => void
}
function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
const updateMutation = useMutation({
mutationFn: (data: { can_read: boolean; can_send: boolean; can_manage: boolean }) =>
permissionsApi.grant(mailboxId, perm.user_id, data),
onSuccess: () => toast.success('Permesso aggiornato'),
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggle = (field: 'can_read' | 'can_send' | 'can_manage') => {
updateMutation.mutate({
can_read: field === 'can_read' ? !perm.can_read : perm.can_read,
can_send: field === 'can_send' ? !perm.can_send : perm.can_send,
can_manage: field === 'can_manage' ? !perm.can_manage : perm.can_manage,
})
}
return (
<tr className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div>
<p className="font-medium">{perm.full_name}</p>
<p className="text-xs text-muted-foreground">{perm.email}</p>
</div>
</td>
<td className="px-4 py-3">
<span className="text-xs bg-muted px-2 py-0.5 rounded">
{ROLE_LABELS[perm.role] || perm.role}
</span>
</td>
{(['can_read', 'can_send', 'can_manage'] as const).map((field) => (
<td key={field} className="px-4 py-3 text-center">
<button
onClick={() => toggle(field)}
className={cn(
'h-5 w-5 rounded border-2 inline-flex items-center justify-center transition-colors',
perm[field]
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/40 bg-background hover:border-primary',
)}
>
{perm[field] && <span className="text-xs"></span>}
</button>
</td>
))}
<td className="px-4 py-3 text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive/10"
onClick={onRevoke}
title="Revoca accesso"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
)
}
// ─── Dialog assegna permesso ──────────────────────────────────────────────────
interface GrantPermissionDialogProps {
mailboxId: string
onClose: () => void
onSaved: () => void
}
function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionDialogProps) {
const [selectedUserId, setSelectedUserId] = useState('')
const [canRead, setCanRead] = useState(true)
const [canSend, setCanSend] = useState(false)
const [canManage, setCanManage] = useState(false)
const { data: usersData } = useQuery({
queryKey: ['users'],
queryFn: () => usersApi.list(1, 100),
})
const grantMutation = useMutation({
mutationFn: () =>
permissionsApi.grant(mailboxId, selectedUserId, {
can_read: canRead,
can_send: canSend,
can_manage: canManage,
}),
onSuccess: () => {
toast.success('Permesso assegnato')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const users = usersData?.items?.filter((u) => u.is_active) || []
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assegna permesso casella</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Utente</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
>
<option value="">Seleziona utente...</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.full_name} ({u.email})
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>Permessi</Label>
<div className="space-y-2">
{[
{ key: 'can_read', label: 'Lettura messaggi', state: canRead, setState: setCanRead },
{ key: 'can_send', label: 'Invio PEC', state: canSend, setState: setCanSend },
{ key: 'can_manage', label: 'Gestione casella', state: canManage, setState: setCanManage },
].map(({ key, label, state, setState }) => (
<label key={key} className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={state}
onChange={(e) => setState(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">{label}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Annulla</Button>
<Button
onClick={() => grantMutation.mutate()}
disabled={!selectedUserId}
isLoading={grantMutation.isPending}
>
Assegna
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+357
View File
@@ -0,0 +1,357 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Users, Plus, Edit, UserX, UserCheck, Key, Shield } from 'lucide-react'
import { useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { usersApi } from '@/api/users.api'
import { getErrorMessage } from '@/api/client'
import { formatDate, ROLE_LABELS } from '@/lib/utils'
import { cn } from '@/lib/utils'
import type { UserCreateRequest, UserResponse, UserRole } from '@/types/api.types'
const ROLE_COLORS: Record<UserRole, string> = {
super_admin: 'bg-purple-100 text-purple-800',
admin: 'bg-blue-100 text-blue-800',
supervisor: 'bg-cyan-100 text-cyan-800',
operator: 'bg-green-100 text-green-800',
readonly: 'bg-gray-100 text-gray-800',
}
export function UsersPage() {
const queryClient = useQueryClient()
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [editingUser, setEditingUser] = useState<UserResponse | null>(null)
const [resetPasswordUser, setResetPasswordUser] = useState<UserResponse | null>(null)
const { data: usersData, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => usersApi.list(1, 100),
})
const toggleActiveMutation = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
usersApi.update(id, { is_active: active }),
onSuccess: (_, vars) => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success(vars.active ? 'Utente riattivato' : 'Utente disabilitato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const users = usersData?.items || []
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Utenti</h1>
<span className="text-sm text-muted-foreground">
({users.filter((u) => u.is_active).length} attivi su {users.length})
</span>
</div>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Nuovo utente
</Button>
</div>
{/* Tabella */}
<div className="flex-1 overflow-auto p-6">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Utente</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Ruolo</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">2FA</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Ultimo accesso</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Stato</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Azioni</th>
</tr>
</thead>
<tbody className="divide-y">
{users.map((user) => (
<tr
key={user.id}
className={cn('hover:bg-muted/30 transition-colors', !user.is_active && 'opacity-60')}
>
<td className="px-4 py-3">
<div>
<p className="font-medium">{user.full_name}</p>
<p className="text-muted-foreground text-xs">{user.email}</p>
</div>
</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
ROLE_COLORS[user.role],
)}
>
{ROLE_LABELS[user.role] || user.role}
</span>
</td>
<td className="px-4 py-3">
{user.totp_enabled ? (
<span className="text-green-600 text-xs font-medium flex items-center gap-1">
<Shield className="h-3 w-3" /> Attivo
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{formatDate(user.last_login_at) || '—'}
</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600',
)}
>
{user.is_active ? 'Attivo' : 'Disabilitato'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{/* Modifica */}
<Button
variant="ghost"
size="icon"
title="Modifica"
onClick={() => setEditingUser(user)}
className="h-8 w-8"
>
<Edit className="h-4 w-4" />
</Button>
{/* Reset password */}
<Button
variant="ghost"
size="icon"
title="Reset password"
onClick={() => setResetPasswordUser(user)}
className="h-8 w-8"
>
<Key className="h-4 w-4" />
</Button>
{/* Attiva/disabilita */}
<Button
variant="ghost"
size="icon"
title={user.is_active ? 'Disabilita' : 'Riattiva'}
onClick={() =>
toggleActiveMutation.mutate({ id: user.id, active: !user.is_active })
}
className="h-8 w-8"
>
{user.is_active ? (
<UserX className="h-4 w-4 text-red-500" />
) : (
<UserCheck className="h-4 w-4 text-green-500" />
)}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Dialog crea/modifica utente */}
{(showCreateDialog || editingUser) && (
<UserFormDialog
open
onClose={() => {
setShowCreateDialog(false)
setEditingUser(null)
}}
editingUser={editingUser}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['users'] })
setShowCreateDialog(false)
setEditingUser(null)
}}
/>
)}
{/* Dialog reset password */}
{resetPasswordUser && (
<ResetPasswordDialog
user={resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
/>
)}
</div>
)
}
// ─── Dialog crea/modifica utente ─────────────────────────────────────────────
interface UserFormDialogProps {
open: boolean
onClose: () => void
editingUser: UserResponse | null
onSaved: () => void
}
function UserFormDialog({ open, onClose, editingUser, onSaved }: UserFormDialogProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserCreateRequest>({
defaultValues: editingUser
? { full_name: editingUser.full_name, role: editingUser.role, email: editingUser.email }
: { role: 'operator' },
})
const createMutation = useMutation({
mutationFn: usersApi.create,
onSuccess: () => {
toast.success('Utente creato')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<UserCreateRequest> }) =>
usersApi.update(id, data),
onSuccess: () => {
toast.success('Utente aggiornato')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const onSubmit = (data: UserCreateRequest) => {
if (editingUser) {
updateMutation.mutate({ id: editingUser.id, data: { full_name: data.full_name, role: data.role } })
} else {
createMutation.mutate(data)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingUser ? 'Modifica utente' : 'Nuovo utente'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nome completo *</Label>
<Input {...register('full_name', { required: 'Obbligatorio' })} />
{errors.full_name && <p className="text-xs text-destructive">{errors.full_name.message}</p>}
</div>
{!editingUser && (
<>
<div className="space-y-2">
<Label>Email *</Label>
<Input type="email" {...register('email', { required: 'Obbligatoria' })} />
</div>
<div className="space-y-2">
<Label>Password *</Label>
<Input
type="password"
placeholder="Min. 8 caratteri, 1 maiuscola, 1 numero"
{...register('password', { required: 'Obbligatoria', minLength: 8 })}
/>
</div>
</>
)}
<div className="space-y-2">
<Label>Ruolo *</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
{...register('role', { required: true })}
>
{Object.entries(ROLE_LABELS)
.filter(([role]) => role !== 'super_admin')
.map(([role, label]) => (
<option key={role} value={role}>{label}</option>
))}
</select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Annulla</Button>
<Button type="submit" isLoading={createMutation.isPending || updateMutation.isPending}>
{editingUser ? 'Salva' : 'Crea utente'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ─── Dialog reset password ────────────────────────────────────────────────────
function ResetPasswordDialog({ user, onClose }: { user: UserResponse; onClose: () => void }) {
const { register, handleSubmit } = useForm<{ new_password: string; confirm: string }>()
const mutation = useMutation({
mutationFn: (pwd: string) => usersApi.resetPassword(user.id, pwd),
onSuccess: () => {
toast.success('Password aggiornata')
onClose()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const onSubmit = (data: { new_password: string }) => {
mutation.mutate(data.new_password)
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset password {user.full_name}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nuova password</Label>
<Input
type="password"
placeholder="Min. 8 caratteri, 1 maiuscola, 1 numero"
{...register('new_password', { required: true, minLength: 8 })}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Annulla</Button>
<Button type="submit" isLoading={mutation.isPending}>Aggiorna password</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Auth Store (Zustand) stato autenticazione globale.
*
* Persiste access_token e refresh_token in localStorage.
* L'utente viene memorizzato in memoria per sicurezza.
*/
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { authApi } from '@/api/auth.api'
import type { LoginRequest, UserResponse } from '@/types/api.types'
interface AuthState {
user: UserResponse | null
accessToken: string | null
refreshToken: string | null
isAuthenticated: boolean
isLoading: boolean
// Actions
login: (credentials: LoginRequest) => Promise<void>
logout: () => Promise<void>
loadUser: () => Promise<void>
setTokens: (access: string, refresh: string) => void
clearAuth: () => void
}
export const useAuthStore = create<AuthState>()(
devtools(
(set, get) => ({
user: null,
accessToken: localStorage.getItem('access_token'),
refreshToken: localStorage.getItem('refresh_token'),
isAuthenticated: !!localStorage.getItem('access_token'),
isLoading: false,
login: async (credentials) => {
set({ isLoading: true })
try {
const tokens = await authApi.login(credentials)
localStorage.setItem('access_token', tokens.access_token)
localStorage.setItem('refresh_token', tokens.refresh_token)
// Carica i dati utente
const user = await authApi.me()
set({
user,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
isAuthenticated: true,
isLoading: false,
})
} catch (error) {
set({ isLoading: false })
throw error
}
},
logout: async () => {
const { refreshToken } = get()
if (refreshToken) {
try {
await authApi.logout(refreshToken)
} catch {
// Ignora errori di logout (token già scaduto)
}
}
get().clearAuth()
},
loadUser: async () => {
const token = localStorage.getItem('access_token')
if (!token) return
set({ isLoading: true })
try {
const user = await authApi.me()
set({ user, isAuthenticated: true, isLoading: false })
} catch {
get().clearAuth()
set({ isLoading: false })
}
},
setTokens: (access, refresh) => {
localStorage.setItem('access_token', access)
localStorage.setItem('refresh_token', refresh)
set({ accessToken: access, refreshToken: refresh, isAuthenticated: true })
},
clearAuth: () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
})
},
}),
{ name: 'PecFlow/Auth' },
),
)
// Selettori tipizzati
export const selectUser = (s: AuthState) => s.user
export const selectIsAdmin = (s: AuthState) =>
s.user?.role === 'admin' || s.user?.role === 'super_admin'
export const selectIsAuthenticated = (s: AuthState) => s.isAuthenticated
+79
View File
@@ -0,0 +1,79 @@
/**
* Inbox Store (Zustand) stato messaggi e filtri.
*/
import { create } from 'zustand'
import type { MessageResponse } from '@/types/api.types'
interface InboxFilters {
mailbox_id?: string
direction?: 'inbound' | 'outbound'
state?: string
is_read?: boolean
is_starred?: boolean
search?: string
page: number
page_size: number
}
interface InboxState {
messages: MessageResponse[]
total: number
filters: InboxFilters
selectedMessageId: string | null
unreadCount: number
isLoading: boolean
setFilters: (filters: Partial<InboxFilters>) => void
resetFilters: () => void
setMessages: (messages: MessageResponse[], total: number) => void
prependMessage: (message: MessageResponse) => void
updateMessage: (id: string, updates: Partial<MessageResponse>) => void
selectMessage: (id: string | null) => void
incrementUnread: () => void
resetUnread: () => void
setLoading: (loading: boolean) => void
}
const DEFAULT_FILTERS: InboxFilters = {
page: 1,
page_size: 50,
}
export const useInboxStore = create<InboxState>()((set) => ({
messages: [],
total: 0,
filters: DEFAULT_FILTERS,
selectedMessageId: null,
unreadCount: 0,
isLoading: false,
setFilters: (filters) =>
set((state) => ({
filters: { ...state.filters, ...filters, page: 1 },
})),
resetFilters: () => set({ filters: DEFAULT_FILTERS }),
setMessages: (messages, total) => set({ messages, total }),
prependMessage: (message) =>
set((state) => ({
messages: [message, ...state.messages],
total: state.total + 1,
unreadCount: state.unreadCount + (message.is_read ? 0 : 1),
})),
updateMessage: (id, updates) =>
set((state) => ({
messages: state.messages.map((m) => (m.id === id ? { ...m, ...updates } : m)),
})),
selectMessage: (id) => set({ selectedMessageId: id }),
incrementUnread: () => set((state) => ({ unreadCount: state.unreadCount + 1 })),
resetUnread: () => set({ unreadCount: 0 }),
setLoading: (loading) => set({ isLoading: loading }),
}))
+60
View File
@@ -0,0 +1,60 @@
/**
* Mailbox Store (Zustand) lista caselle PEC.
*/
import { create } from 'zustand'
import type { MailboxResponse } from '@/types/api.types'
interface MailboxState {
mailboxes: MailboxResponse[]
selectedMailboxId: string | null
isLoading: boolean
setMailboxes: (mailboxes: MailboxResponse[]) => void
upsertMailbox: (mailbox: MailboxResponse) => void
removeMailbox: (id: string) => void
selectMailbox: (id: string | null) => void
updateMailboxStatus: (id: string, status: string, errorMsg?: string) => void
setLoading: (loading: boolean) => void
}
export const useMailboxStore = create<MailboxState>()((set) => ({
mailboxes: [],
selectedMailboxId: null,
isLoading: false,
setMailboxes: (mailboxes) => set({ mailboxes }),
upsertMailbox: (mailbox) =>
set((state) => {
const idx = state.mailboxes.findIndex((m) => m.id === mailbox.id)
if (idx >= 0) {
const updated = [...state.mailboxes]
updated[idx] = mailbox
return { mailboxes: updated }
}
return { mailboxes: [...state.mailboxes, mailbox] }
}),
removeMailbox: (id) =>
set((state) => ({
mailboxes: state.mailboxes.filter((m) => m.id !== id),
})),
selectMailbox: (id) => set({ selectedMailboxId: id }),
updateMailboxStatus: (id, status, errorMsg) =>
set((state) => ({
mailboxes: state.mailboxes.map((m) =>
m.id === id
? {
...m,
status: status as MailboxResponse['status'],
sync_error_msg: errorMsg ?? m.sync_error_msg,
}
: m,
),
})),
setLoading: (loading) => set({ isLoading: loading }),
}))
+309
View File
@@ -0,0 +1,309 @@
// ─── Auth ────────────────────────────────────────────────────────────────────
export interface LoginRequest {
email: string
password: string
totp_code?: string
}
export interface TokenResponse {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
export interface TOTPSetupResponse {
secret: string
qr_uri: string
qr_image_base64: string
}
export interface TOTPStatusResponse {
totp_enabled: boolean
}
// ─── User ─────────────────────────────────────────────────────────────────────
export type UserRole = 'super_admin' | 'admin' | 'supervisor' | 'operator' | 'readonly'
export interface UserResponse {
id: string
tenant_id: string
email: string
full_name: string
role: UserRole
is_active: boolean
totp_enabled: boolean
last_login_at: string | null
created_at: string
updated_at: string
}
export interface UserListResponse {
items: UserResponse[]
total: number
page: number
page_size: number
pages: number
}
export interface UserCreateRequest {
email: string
password: string
full_name: string
role: UserRole
}
export interface UserUpdateRequest {
full_name?: string
role?: UserRole
is_active?: boolean
}
// ─── Mailbox ──────────────────────────────────────────────────────────────────
export type MailboxStatus = 'active' | 'paused' | 'error' | 'deleted'
export interface MailboxResponse {
id: string
tenant_id: string
email_address: string
display_name: string | null
provider: string | null
imap_host: string
imap_port: number
imap_use_ssl: boolean
smtp_host: string
smtp_port: number
smtp_use_tls: boolean
status: MailboxStatus
last_sync_at: string | null
last_sync_uid: number | null
sync_error_msg: string | null
sync_error_count: number
created_by: string | null
created_at: string
updated_at: string
}
export interface MailboxListResponse {
items: MailboxResponse[]
total: number
page: number
page_size: number
}
export interface MailboxCreateRequest {
email_address: string
display_name?: string
provider?: string
imap_host: string
imap_port: number
imap_user: string
imap_pass: string
imap_use_ssl: boolean
smtp_host: string
smtp_port: number
smtp_user: string
smtp_pass: string
smtp_use_tls: boolean
}
export interface MailboxUpdateRequest {
display_name?: string
provider?: string
status?: 'active' | 'paused'
imap_host?: string
imap_port?: number
imap_user?: string
imap_pass?: string
imap_use_ssl?: boolean
smtp_host?: string
smtp_port?: number
smtp_user?: string
smtp_pass?: string
smtp_use_tls?: boolean
}
export interface ConnectionTestResult {
success: boolean
message: string
latency_ms: number | null
capabilities: string[] | null
}
// ─── Message ──────────────────────────────────────────────────────────────────
export type PecDirection = 'inbound' | 'outbound'
export type PecState =
| 'draft'
| 'queued'
| 'sent'
| 'accepted'
| 'delivered'
| 'anomaly'
| 'failed'
| 'received'
export type PecMsgType =
| 'posta_certificata'
| 'accettazione'
| 'non_accettazione'
| 'presa_in_carico'
| 'avvenuta_consegna'
| 'mancata_consegna'
| 'errore_consegna'
| 'preavviso_mancata_consegna'
| 'rilevazione_virus'
| 'unknown'
export interface MessageResponse {
id: string
tenant_id: string
mailbox_id: string
message_id_header: string | null
imap_uid: number | null
imap_folder: string
direction: PecDirection
pec_type: PecMsgType
state: PecState
subject: string | null
from_address: string | null
to_addresses: string[]
cc_addresses: string[]
sent_at: string | null
received_at: string | null
size_bytes: number | null
body_text: string | null
body_html: string | null
has_attachments: boolean
parent_message_id: string | null
is_read: boolean
is_starred: boolean
is_archived: boolean
archived_at: string | null
raw_eml_path: string | null
created_at: string
updated_at: string
}
export interface MessageListResponse {
items: MessageResponse[]
total: number
page: number
page_size: number
}
// ─── Attachment ───────────────────────────────────────────────────────────────
export interface AttachmentResponse {
id: string
message_id: string
filename: string
content_type: string | null
size_bytes: number | null
storage_path: string
checksum_sha256: string | null
created_at: string
}
// ─── Send Job ─────────────────────────────────────────────────────────────────
export type SendJobStatus = 'pending' | 'sending' | 'sent' | 'failed' | 'retrying'
export interface SendJobResponse {
id: string
tenant_id: string
mailbox_id: string
message_id: string | null
status: SendJobStatus
attempt_count: number
max_attempts: number
next_retry_at: string | null
last_error: string | null
queued_at: string
sent_at: string | null
created_by: string | null
}
export interface SendJobListResponse {
items: SendJobResponse[]
total: number
page: number
page_size: number
}
export interface SendPecRequest {
mailbox_id: string
to_addresses: string[]
cc_addresses?: string[]
subject: string
body_text?: string
body_html?: string
reply_to_message_id?: string
}
// ─── Permission ───────────────────────────────────────────────────────────────
export interface PermissionResponse {
id: string
tenant_id: string
user_id: string
mailbox_id: string
can_read: boolean
can_send: boolean
can_manage: boolean
granted_by: string | null
granted_at: string
}
export interface PermissionGrantRequest {
can_read?: boolean
can_send?: boolean
can_manage?: boolean
}
export interface MailboxUserPermissionResponse {
user_id: string
email: string
full_name: string
role: UserRole
can_read: boolean
can_send: boolean
can_manage: boolean
granted_at: string
}
export interface UserMailboxPermissionResponse {
mailbox_id: string
email_address: string
display_name: string | null
can_read: boolean
can_send: boolean
can_manage: boolean
}
// ─── WebSocket events ─────────────────────────────────────────────────────────
export type WsEventType =
| 'mailbox:new_message'
| 'mailbox:status_changed'
| 'send_job:status_changed'
| 'send_job:anomaly'
export interface WsEvent {
type: WsEventType
tenant_id: string
payload: Record<string, unknown>
}
// ─── API Pagination ──────────────────────────────────────────────────────────
export interface PaginationParams {
page?: number
page_size?: number
}
export interface ApiError {
detail: string | Array<{ loc: string[]; msg: string; type: string }>
}