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