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