Multitenancy

This commit is contained in:
2026-03-19 18:06:44 +01:00
parent 106ed50361
commit e594defc00
15 changed files with 1090 additions and 37 deletions
+4
View File
@@ -10,6 +10,7 @@ import { PermissionsPage } from '@/pages/Permissions/PermissionsPage'
import { SettingsPage } from '@/pages/Settings/SettingsPage'
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -70,6 +71,9 @@ export default function App() {
<Route path="/virtual-boxes" element={<VirtualBoxesPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
{/* Super Admin Gestione Multi-Tenant */}
<Route path="/multitenant" element={<MultiTenantPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+28
View File
@@ -0,0 +1,28 @@
import { apiClient } from './client'
import type { TenantResponse, TenantCreateRequest, TenantUpdateRequest } from '@/types/api.types'
export const tenantsApi = {
list(): Promise<TenantResponse[]> {
return apiClient.get<TenantResponse[]>('/tenants').then((r) => r.data)
},
get(id: string): Promise<TenantResponse> {
return apiClient.get<TenantResponse>(`/tenants/${id}`).then((r) => r.data)
},
create(data: TenantCreateRequest): Promise<TenantResponse> {
return apiClient.post<TenantResponse>('/tenants', data).then((r) => r.data)
},
update(id: string, data: TenantUpdateRequest): Promise<TenantResponse> {
return apiClient.patch<TenantResponse>(`/tenants/${id}`, data).then((r) => r.data)
},
suspend(id: string): Promise<TenantResponse> {
return apiClient.patch<TenantResponse>(`/tenants/${id}`, { is_active: false }).then((r) => r.data)
},
activate(id: string): Promise<TenantResponse> {
return apiClient.patch<TenantResponse>(`/tenants/${id}`, { is_active: true }).then((r) => r.data)
},
}
+36 -1
View File
@@ -48,6 +48,7 @@ import {
Bell,
Star,
Archive,
Building2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
@@ -71,7 +72,7 @@ export function Sidebar() {
/** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */
const [collapsedVboxes, setCollapsedVboxes] = useState<Set<string>>(new Set())
const { user, isAdmin, logout } = useAuth()
const { user, isAdmin, isSuperAdmin, logout } = useAuth()
const unreadCount = useInboxStore((s) => s.unreadCount)
// Le caselle PEC vengono caricate qui e condivise via React Query cache
@@ -387,6 +388,40 @@ export function Sidebar() {
</div>
</div>
)}
{/* ── Sezione Super Admin visibile solo ai super_admin ── */}
{isSuperAdmin && (
<div>
{!collapsed && (
<>
<div className="border-t border-purple-900/50 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-purple-400 uppercase tracking-wider">
Super Admin
</p>
</>
)}
{collapsed && <div className="border-t border-purple-900/50 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
<NavLink
to="/multitenant"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-purple-700 text-white'
: 'text-purple-300 hover:bg-purple-900/40 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Multi-Tenant' : undefined}
>
<Building2 className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Multi-Tenant</span>}
</NavLink>
</div>
</div>
)}
</nav>
{/* ── Profilo utente + logout ── */}
@@ -0,0 +1,577 @@
/**
* Pannello di gestione multi-tenant accessibile solo ai super_admin.
*
* Funzionalita':
* - Lista tutti i tenant con statistiche (utenti, caselle, piano)
* - Crea nuovo tenant con admin iniziale
* - Modifica nome, piano e limiti
* - Sospendi / Riattiva tenant (toggle is_active)
*
* Route: /multitenant
* Guard: reindirizza a /inbox se l'utente non e' super_admin
*/
import { useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import {
Building2,
Plus,
Pencil,
PauseCircle,
PlayCircle,
Users,
MailCheck,
RefreshCw,
} from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
import { tenantsApi } from '@/api/tenants.api'
import type { TenantResponse, TenantCreateRequest, TenantUpdateRequest, TenantPlan } from '@/types/api.types'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Card } from '@/components/ui/Card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
// ─── Select nativo stilizzato ─────────────────────────────────────────────────
function NativeSelect({
id,
value,
onChange,
children,
}: {
id?: string
value: string
onChange: (v: string) => void
children: React.ReactNode
}) {
return (
<select
id={id}
value={value}
onChange={e => onChange(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
{children}
</select>
)
}
// ─── Badge piano ──────────────────────────────────────────────────────────────
function PlanBadge({ plan }: { plan: TenantPlan }) {
const styles: Record<TenantPlan, string> = {
starter: 'bg-gray-100 text-gray-700',
pro: 'bg-blue-100 text-blue-700',
enterprise: 'bg-purple-100 text-purple-700',
}
const labels: Record<TenantPlan, string> = {
starter: 'Starter',
pro: 'Pro',
enterprise: 'Enterprise',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${styles[plan]}`}>
{labels[plan]}
</span>
)
}
// ─── Badge stato ─────────────────────────────────────────────────────────────
function StatusBadge({ isActive }: { isActive: boolean }) {
return isActive ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Attivo
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
Sospeso
</span>
)
}
// ─── Dialog: Crea tenant ──────────────────────────────────────────────────────
interface CreateDialogProps {
open: boolean
onClose: () => void
onCreated: () => void
}
function CreateTenantDialog({ open, onClose, onCreated }: CreateDialogProps) {
const emptyForm: TenantCreateRequest = {
slug: '',
name: '',
plan: 'starter',
max_mailboxes: 5,
max_users: 10,
admin_email: '',
admin_password: '',
admin_full_name: '',
}
const [form, setForm] = useState<TenantCreateRequest>(emptyForm)
const mutation = useMutation({
mutationFn: () => tenantsApi.create(form),
onSuccess: () => {
toast.success('Tenant creato con successo')
onCreated()
onClose()
setForm(emptyForm)
},
onError: (err: unknown) => {
const msg = (err as any)?.response?.data?.detail ?? 'Errore nella creazione del tenant'
toast.error(typeof msg === 'string' ? msg : 'Errore nella creazione')
},
})
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Nuovo Tenant</DialogTitle>
</DialogHeader>
<form
onSubmit={e => { e.preventDefault(); mutation.mutate() }}
className="space-y-4 py-2"
>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="ct-slug">Slug *</Label>
<Input
id="ct-slug"
value={form.slug}
onChange={e => setForm(f => ({ ...f, slug: e.target.value.toLowerCase() }))}
placeholder="acme-corp"
required
minLength={3}
maxLength={63}
/>
<p className="text-xs text-gray-500">Lettere minuscole, numeri e trattini</p>
</div>
<div className="space-y-1">
<Label htmlFor="ct-name">Nome organizzazione *</Label>
<Input
id="ct-name"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="Acme Corp SpA"
required
minLength={2}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label htmlFor="ct-plan">Piano</Label>
<NativeSelect
id="ct-plan"
value={form.plan ?? 'starter'}
onChange={v => setForm(f => ({ ...f, plan: v as TenantPlan }))}
>
<option value="starter">Starter</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</NativeSelect>
</div>
<div className="space-y-1">
<Label htmlFor="ct-mb">Max caselle</Label>
<Input
id="ct-mb"
type="number"
min={1}
max={1000}
value={form.max_mailboxes}
onChange={e => setForm(f => ({ ...f, max_mailboxes: Number(e.target.value) }))}
/>
</div>
<div className="space-y-1">
<Label htmlFor="ct-mu">Max utenti</Label>
<Input
id="ct-mu"
type="number"
min={1}
max={1000}
value={form.max_users}
onChange={e => setForm(f => ({ ...f, max_users: Number(e.target.value) }))}
/>
</div>
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-3">Utente Admin iniziale</p>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="ct-fname">Nome completo *</Label>
<Input
id="ct-fname"
value={form.admin_full_name}
onChange={e => setForm(f => ({ ...f, admin_full_name: e.target.value }))}
placeholder="Mario Rossi"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="ct-email">Email admin *</Label>
<Input
id="ct-email"
type="email"
value={form.admin_email}
onChange={e => setForm(f => ({ ...f, admin_email: e.target.value }))}
placeholder="admin@acme.it"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="ct-pwd">Password admin *</Label>
<Input
id="ct-pwd"
type="password"
value={form.admin_password}
onChange={e => setForm(f => ({ ...f, admin_password: e.target.value }))}
placeholder="Almeno 8 caratteri"
required
minLength={8}
/>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Annulla
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creazione...' : 'Crea Tenant'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ─── Dialog: Modifica tenant ──────────────────────────────────────────────────
interface EditDialogProps {
tenant: TenantResponse | null
onClose: () => void
onSaved: () => void
}
function EditTenantDialog({ tenant, onClose, onSaved }: EditDialogProps) {
const [form, setForm] = useState<TenantUpdateRequest>({
name: tenant?.name ?? '',
plan: tenant?.plan ?? 'starter',
max_mailboxes: tenant?.max_mailboxes ?? 5,
max_users: tenant?.max_users ?? 10,
})
const mutation = useMutation({
mutationFn: () => tenantsApi.update(tenant!.id, form),
onSuccess: () => {
toast.success('Tenant aggiornato')
onSaved()
onClose()
},
onError: (err: unknown) => {
const msg = (err as any)?.response?.data?.detail ?? "Errore nell'aggiornamento"
toast.error(typeof msg === 'string' ? msg : "Errore nell'aggiornamento")
},
})
if (!tenant) return null
return (
<Dialog open={!!tenant} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Modifica {tenant.slug}</DialogTitle>
</DialogHeader>
<form
onSubmit={e => { e.preventDefault(); mutation.mutate() }}
className="space-y-4 py-2"
>
<div className="space-y-1">
<Label htmlFor="et-name">Nome organizzazione</Label>
<Input
id="et-name"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label htmlFor="et-plan">Piano</Label>
<NativeSelect
id="et-plan"
value={form.plan ?? 'starter'}
onChange={v => setForm(f => ({ ...f, plan: v as TenantPlan }))}
>
<option value="starter">Starter</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</NativeSelect>
</div>
<div className="space-y-1">
<Label htmlFor="et-mb">Max caselle</Label>
<Input
id="et-mb"
type="number"
min={1}
max={1000}
value={form.max_mailboxes}
onChange={e => setForm(f => ({ ...f, max_mailboxes: Number(e.target.value) }))}
/>
</div>
<div className="space-y-1">
<Label htmlFor="et-mu">Max utenti</Label>
<Input
id="et-mu"
type="number"
min={1}
max={1000}
value={form.max_users}
onChange={e => setForm(f => ({ ...f, max_users: Number(e.target.value) }))}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Annulla
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Salvataggio...' : 'Salva'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function MultiTenantPage() {
const { isSuperAdmin, isLoading: authLoading, user, isAuthenticated } = useAuth()
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [editTenant, setEditTenant] = useState<TenantResponse | null>(null)
const { data: tenants = [], isLoading, refetch } = useQuery({
queryKey: ['tenants'],
queryFn: () => tenantsApi.list(),
staleTime: 30_000,
enabled: isSuperAdmin,
})
const toggleMutation = useMutation({
mutationFn: (t: TenantResponse) =>
t.is_active ? tenantsApi.suspend(t.id) : tenantsApi.activate(t.id),
onSuccess: (updated) => {
toast.success(
updated.is_active
? `Tenant "${updated.slug}" riattivato`
: `Tenant "${updated.slug}" sospeso`
)
queryClient.invalidateQueries({ queryKey: ['tenants'] })
},
onError: () => toast.error('Errore durante la modifica dello stato'),
})
// Aspetta che l'utente sia caricato prima di valutare i permessi
if (authLoading || (isAuthenticated && !user)) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-purple-600 border-t-transparent" />
</div>
)
}
// Guard: solo super_admin
if (!isSuperAdmin) {
return <Navigate to="/inbox" replace />
}
return (
<div className="flex-1 overflow-auto p-6">
{/* ── Intestazione ── */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-purple-600 flex items-center justify-center">
<Building2 className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestione Multi-Tenant</h1>
<p className="text-sm text-gray-500">
{tenants.length} organizzazion{tenants.length !== 1 ? 'i' : 'e'} registrate
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { refetch(); toast.success('Lista aggiornata') }}>
<RefreshCw className="h-4 w-4 mr-1" />
Aggiorna
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Nuovo Tenant
</Button>
</div>
</div>
{/* ── Statistiche aggregate ── */}
<div className="grid grid-cols-3 gap-4 mb-6">
<Card className="p-4">
<p className="text-sm text-gray-500">Totale tenant</p>
<p className="text-2xl font-bold text-gray-900">{tenants.length}</p>
</Card>
<Card className="p-4">
<p className="text-sm text-gray-500">Attivi</p>
<p className="text-2xl font-bold text-green-600">
{tenants.filter(t => t.is_active).length}
</p>
</Card>
<Card className="p-4">
<p className="text-sm text-gray-500">Sospesi</p>
<p className="text-2xl font-bold text-red-600">
{tenants.filter(t => !t.is_active).length}
</p>
</Card>
</div>
{/* ── Tabella tenant ── */}
<Card className="overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-16 text-gray-400">
Caricamento...
</div>
) : tenants.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 gap-3">
<Building2 className="h-10 w-10 opacity-30" />
<p>Nessun tenant registrato</p>
<Button size="sm" onClick={() => setCreateOpen(true)}>
Crea il primo tenant
</Button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600">Organizzazione</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Piano</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Stato</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">Utenti</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">Caselle</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Creato il</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Azioni</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tenants.map(tenant => (
<tr
key={tenant.id}
className={`hover:bg-gray-50 transition-colors ${!tenant.is_active ? 'opacity-60' : ''}`}
>
<td className="px-4 py-3">
<p className="font-medium text-gray-900">{tenant.name}</p>
<p className="text-xs text-gray-400 font-mono">{tenant.slug}</p>
</td>
<td className="px-4 py-3">
<PlanBadge plan={tenant.plan} />
</td>
<td className="px-4 py-3">
<StatusBadge isActive={tenant.is_active} />
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1 text-gray-600">
<Users className="h-3.5 w-3.5" />
<span className="font-medium">{tenant.user_count}</span>
<span className="text-gray-400">/ {tenant.max_users}</span>
</div>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1 text-gray-600">
<MailCheck className="h-3.5 w-3.5" />
<span className="font-medium">{tenant.mailbox_count}</span>
<span className="text-gray-400">/ {tenant.max_mailboxes}</span>
</div>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{new Date(tenant.created_at).toLocaleDateString('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => setEditTenant(tenant)}
title="Modifica"
className="p-1.5 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => toggleMutation.mutate(tenant)}
disabled={toggleMutation.isPending}
title={tenant.is_active ? 'Sospendi' : 'Riattiva'}
className={`p-1.5 rounded transition-colors ${
tenant.is_active
? 'hover:bg-red-50 text-gray-400 hover:text-red-600'
: 'hover:bg-green-50 text-gray-400 hover:text-green-600'
}`}
>
{tenant.is_active
? <PauseCircle className="h-4 w-4" />
: <PlayCircle className="h-4 w-4" />
}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* ── Dialog: crea tenant ── */}
<CreateTenantDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={() => queryClient.invalidateQueries({ queryKey: ['tenants'] })}
/>
{/* ── Dialog: modifica tenant ── */}
<EditTenantDialog
tenant={editTenant}
onClose={() => setEditTenant(null)}
onSaved={() => queryClient.invalidateQueries({ queryKey: ['tenants'] })}
/>
</div>
)
}
+37
View File
@@ -1,3 +1,40 @@
// ─── Tenant (super-admin) ────────────────────────────────────────────────────
export type TenantPlan = 'starter' | 'pro' | 'enterprise'
export interface TenantResponse {
id: string
slug: string
name: string
plan: TenantPlan
is_active: boolean
max_mailboxes: number
max_users: number
created_at: string
updated_at: string
user_count: number
mailbox_count: number
}
export interface TenantCreateRequest {
slug: string
name: string
plan?: TenantPlan
max_mailboxes?: number
max_users?: number
admin_email: string
admin_password: string
admin_full_name: string
}
export interface TenantUpdateRequest {
name?: string
plan?: TenantPlan
is_active?: boolean
max_mailboxes?: number
max_users?: number
}
// ─── Auth ────────────────────────────────────────────────────────────────────
export interface LoginRequest {