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
+11 -2
View File
@@ -1,8 +1,8 @@
Stiamo lavorando a un PEC Manager SaaS
Effettua tutti i test in locale
Non usare emoji
Ho docker installato, compose v2 (docker cmpose senza trattino)
Qualunque modifica deve essere deployata sul server remoto
Non fare commit sul repository GitHub, ci penso io
@@ -40,3 +40,12 @@ Super Admin superadmin@pechub.it SuperAdmin@PEChub2026!
Admin (tenant demo) admin@demo.pechub.it Demo@PEChub2026!
Operator (tenant demo) operator@demo.pechub.it Oper@PEChub2026!
Per accedere all'applicazione usa le credenziali Admin del tenant demo.
Il server su cui deployare è 212.83.140.21, porta 22222
Username: root
Password: Ma212718!
Ho docker installato, compose v2 (docker cmpose senza trattino)
Il progetto si trova in `/opt/pechub`
Il servizio deve essere esposto su 0.0.0.0 e prevedere l'esposizione anche col dominio pechub.it
@@ -0,0 +1,153 @@
"""RLS completa su tutte le tabelle applicative + ruolo pechub_app
Revision ID: 0006
Revises: 0005
Create Date: 2026-03-19 00:00:00.000000
Aggiunge:
- ENABLE ROW LEVEL SECURITY + policy USING (tenant_id = ...) su tutte le
tabelle applicative che mancavano (attachments, send_jobs, archival_batches,
archival_dips, labels, mailbox_permissions, virtual_boxes,
notification_channels, notification_rules, notification_log, tenant_settings)
- Crea il ruolo PostgreSQL applicativo 'pechub_app' (non-superuser) con
GRANT SELECT/INSERT/UPDATE/DELETE su tutte le tabelle.
Il ruolo viene usato dalla connessione applicativa (DATABASE_URL) mentre
Alembic continua ad usare l'utente superuser (DATABASE_URL_SYNC).
Nota: la migrazione usa DO $$ BEGIN ... EXCEPTION WHEN ... END $$ per
rendere idempotente la creazione del ruolo e delle policy.
"""
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
# Tabelle con tenant_id che devono avere RLS (escluse quelle già gestite in 0001)
_RLS_TABLES = [
"attachments",
"send_jobs",
"archival_batches",
"archival_dips",
"labels",
"mailbox_permissions",
"virtual_boxes",
"notification_channels",
"notification_rules",
"notification_log",
"tenant_settings",
]
# Tutte le tabelle su cui pechub_app ha SELECT/INSERT/UPDATE/DELETE
_ALL_APP_TABLES = [
"tenants",
"users",
"refresh_tokens",
"mailboxes",
"messages",
"attachments",
"send_jobs",
"archival_batches",
"archival_batch_messages",
"archival_dips",
"labels",
"message_labels",
"mailbox_permissions",
"virtual_boxes",
"virtual_box_rules",
"virtual_box_assignments",
"virtual_box_mailboxes",
"notification_channels",
"notification_rules",
"notification_log",
"audit_log",
"tenant_settings",
]
def upgrade() -> None:
# ── 1. Abilita RLS + policy su tabelle mancanti ───────────────────────────
for table in _RLS_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
# Politica idempotente: elimina la policy se esiste e la ricrea
op.execute(
f"""
DO $$
BEGIN
-- Elimina policy esistente (se presente da un run precedente)
DROP POLICY IF EXISTS {table}_tenant_isolation ON {table};
-- Crea la nuova policy
CREATE POLICY {table}_tenant_isolation ON {table}
USING (
tenant_id = NULLIF(
current_setting('app.current_tenant_id', TRUE), ''
)::UUID
);
END
$$;
"""
)
# ── 2. Crea ruolo applicativo pechub_app (non-superuser) ──────────────────
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = 'pechub_app'
) THEN
CREATE USER pechub_app WITH PASSWORD 'pechub_app_password'
NOSUPERUSER NOCREATEDB NOCREATEROLE;
RAISE NOTICE 'Ruolo pechub_app creato';
ELSE
RAISE NOTICE 'Ruolo pechub_app già esistente skip';
END IF;
END
$$;
"""
)
# ── 3. Permessi al ruolo applicativo ──────────────────────────────────────
op.execute("GRANT CONNECT ON DATABASE pechub TO pechub_app")
op.execute("GRANT USAGE ON SCHEMA public TO pechub_app")
for table in _ALL_APP_TABLES:
op.execute(
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO pechub_app"
)
# Sequenze (per BIGSERIAL come audit_log.id)
op.execute("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO pechub_app")
# Default privileges: le tabelle create nelle migrazioni future ricevono
# automaticamente i permessi per pechub_app
op.execute(
"""
ALTER DEFAULT PRIVILEGES FOR ROLE pechub IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO pechub_app;
"""
)
op.execute(
"""
ALTER DEFAULT PRIVILEGES FOR ROLE pechub IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO pechub_app;
"""
)
def downgrade() -> None:
# Rimuovi policy e disabilita RLS
for table in _RLS_TABLES:
op.execute(
f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}"
)
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
# Revoca permessi e rimuovi ruolo
op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA public FROM pechub_app")
op.execute("REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM pechub_app")
op.execute("REVOKE USAGE ON SCHEMA public FROM pechub_app")
op.execute("REVOKE CONNECT ON DATABASE pechub FROM pechub_app")
op.execute("DROP USER IF EXISTS pechub_app")
+20 -13
View File
@@ -1,36 +1,44 @@
"""
Router tenant gestione organizzazioni (solo super_admin).
Protezione doppia:
1. require_super_admin JWT con ruolo super_admin
2. verify_admin_key Header X-Admin-Key (se configurata in produzione)
Endpoint:
GET /api/v1/tenants → lista tenant
GET /api/v1/tenants → lista tenant con statistiche
POST /api/v1/tenants → crea tenant + admin
GET /api/v1/tenants/{id} → dettaglio tenant
PATCH /api/v1/tenants/{id} → modifica tenant
GET /api/v1/tenants/{id} → dettaglio tenant con statistiche
PATCH /api/v1/tenants/{id} → modifica tenant (incluso is_active per sospensione)
"""
import uuid
from typing import Annotated
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from app.dependencies import SuperAdminUser, DB
from app.dependencies import DB, SuperAdminUser, verify_admin_key
from app.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest
from app.services.tenant_service import TenantService
router = APIRouter(prefix="/tenants", tags=["Tenant (super-admin)"])
router = APIRouter(
prefix="/tenants",
tags=["Tenant (super-admin)"],
dependencies=[Depends(verify_admin_key)], # X-Admin-Key su tutti gli endpoint
)
@router.get(
"",
response_model=list[TenantResponse],
summary="Lista tutti i tenant",
summary="Lista tutti i tenant con statistiche",
)
async def list_tenants(
_: SuperAdminUser,
db: DB,
) -> list[TenantResponse]:
service = TenantService(db)
tenants = await service.list_tenants()
return [TenantResponse.model_validate(t) for t in tenants]
return await service.list_tenants_with_stats()
@router.post(
@@ -52,7 +60,7 @@ async def create_tenant(
@router.get(
"/{tenant_id}",
response_model=TenantResponse,
summary="Dettaglio tenant",
summary="Dettaglio tenant con statistiche",
)
async def get_tenant(
tenant_id: uuid.UUID,
@@ -60,14 +68,13 @@ async def get_tenant(
db: DB,
) -> TenantResponse:
service = TenantService(db)
tenant = await service.get_tenant(tenant_id)
return TenantResponse.model_validate(tenant)
return await service.get_tenant_with_stats(tenant_id)
@router.patch(
"/{tenant_id}",
response_model=TenantResponse,
summary="Modifica tenant",
summary="Modifica tenant (nome, piano, limiti, sospensione)",
)
async def update_tenant(
tenant_id: uuid.UUID,
+5
View File
@@ -47,6 +47,11 @@ class Settings(BaseSettings):
minio_bucket: str = "pechub"
minio_use_ssl: bool = False
# ── Admin sicurezza ───────────────────────────────────────────────────────
# Header X-Admin-Key richiesto sugli endpoint /api/v1/tenants
# Se vuoto → protezione disabilitata (solo sviluppo)
admin_secret_key: str = ""
# ── CORS ──────────────────────────────────────────────────────────────────
cors_origins: str = "http://localhost:3000,http://localhost:5173"
+8
View File
@@ -53,6 +53,14 @@ class AccountDisabledError(HTTPException):
)
class TenantSuspendedError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant sospeso",
)
class TOTPRequiredError(HTTPException):
def __init__(self) -> None:
super().__init__(
+55 -4
View File
@@ -1,19 +1,21 @@
"""
Dependency FastAPI get_db, get_current_user, require_admin, RLS middleware.
Dependency FastAPI get_db, get_current_user, get_current_tenant, role guards.
"""
import uuid
from typing import Annotated
from fastapi import Depends, Request
from fastapi import Depends, Header, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ForbiddenError, TokenInvalidError
from app.config import get_settings
from app.core.exceptions import ForbiddenError, TenantSuspendedError, TokenInvalidError
from app.core.security import decode_token
from app.database import get_db
from app.models.tenant import Tenant
from app.models.user import User
from sqlalchemy import select
@@ -58,7 +60,7 @@ async def get_current_user(
) -> User:
"""
Estrae e valida il JWT dall'header Authorization: Bearer <token>.
Carica l'utente dal DB e imposta RLS.
Carica il tenant (verifica is_active), l'utente dal DB e imposta RLS.
"""
token = credentials.credentials
@@ -85,6 +87,13 @@ async def get_current_user(
# Imposta RLS per questo tenant (no-op su SQLite/test)
await _set_rls_tenant_id(db, tenant_id)
# Verifica tenant esiste e non è sospeso
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise TokenInvalidError()
if not tenant.is_active:
raise TenantSuspendedError()
# Carica utente
result = await db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
@@ -98,9 +107,32 @@ async def get_current_user(
from app.core.exceptions import AccountDisabledError
raise AccountDisabledError()
# Attacca il tenant all'utente per uso nei router (evita doppio caricamento)
user._current_tenant = tenant # type: ignore[attr-defined]
return user
# ─── Tenant corrente ──────────────────────────────────────────────────────────
async def get_current_tenant(
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
) -> Tenant:
"""
Restituisce l'oggetto Tenant dell'utente autenticato.
Il Tenant è già stato validato in get_current_user() questa dependency
utilizza la cache della sessione SQLAlchemy (identity map) per evitare
un secondo accesso al DB.
"""
# Usa la cache della sessione ORM (non emette una seconda query)
tenant = await db.get(Tenant, current_user.tenant_id)
if not tenant:
raise TokenInvalidError()
return tenant
# ─── Role guards ──────────────────────────────────────────────────────────────
async def require_admin(
@@ -121,8 +153,27 @@ async def require_super_admin(
return current_user
# ─── Protezione endpoint admin con X-Admin-Key header ─────────────────────────
async def verify_admin_key(
x_admin_key: str = Header(default="", alias="X-Admin-Key"),
) -> None:
"""
Verifica l'header X-Admin-Key per gli endpoint di amministrazione tenant.
Se ADMIN_SECRET_KEY non è configurata (ambiente di sviluppo), il check
viene saltato per facilitare lo sviluppo locale.
"""
settings = get_settings()
if not settings.admin_secret_key:
# Sviluppo: nessuna chiave configurata → accesso libero
return
if x_admin_key != settings.admin_secret_key:
raise ForbiddenError("X-Admin-Key non valida o mancante")
# ─── Tipo annotato per ridurre boilerplate negli endpoint ─────────────────────
CurrentUser = Annotated[User, Depends(get_current_user)]
CurrentTenant = Annotated[Tenant, Depends(get_current_tenant)]
AdminUser = Annotated[User, Depends(require_admin)]
SuperAdminUser = Annotated[User, Depends(require_super_admin)]
DB = Annotated[AsyncSession, Depends(get_db)]
+4
View File
@@ -51,4 +51,8 @@ class TenantResponse(BaseModel):
created_at: datetime
updated_at: datetime
# Statistiche opzionali (popolate dalla lista)
user_count: int = 0
mailbox_count: int = 0
model_config = {"from_attributes": True}
+63 -2
View File
@@ -4,14 +4,15 @@ Servizio tenant gestione organizzazioni (solo super_admin).
import uuid
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.core.security import hash_password
from app.models.mailbox import Mailbox
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.tenant import TenantCreateRequest, TenantUpdateRequest
from app.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest
class TenantService:
@@ -56,12 +57,72 @@ class TenantService:
raise NotFoundError("tenant")
return tenant
async def get_tenant_with_stats(self, tenant_id: uuid.UUID) -> TenantResponse:
"""Restituisce il tenant con conteggi utenti e caselle."""
tenant = await self.get_tenant(tenant_id)
user_count = (
await self.db.execute(
select(func.count(User.id)).where(
User.tenant_id == tenant_id,
User.is_active == True, # noqa: E712
)
)
).scalar_one()
mailbox_count = (
await self.db.execute(
select(func.count(Mailbox.id)).where(
Mailbox.tenant_id == tenant_id,
Mailbox.status != "deleted",
)
)
).scalar_one()
resp = TenantResponse.model_validate(tenant)
resp.user_count = user_count
resp.mailbox_count = mailbox_count
return resp
async def list_tenants(self) -> list[Tenant]:
result = await self.db.execute(
select(Tenant).order_by(Tenant.created_at.desc())
)
return list(result.scalars().all())
async def list_tenants_with_stats(self) -> list[TenantResponse]:
"""
Restituisce tutti i tenant con conteggi utenti e caselle in una
singola query efficiente (LEFT JOIN con GROUP BY).
"""
stmt = (
select(
Tenant,
func.count(User.id.distinct()).label("user_count"),
func.count(Mailbox.id.distinct()).label("mailbox_count"),
)
.outerjoin(
User,
(User.tenant_id == Tenant.id) & (User.is_active == True), # noqa: E712
)
.outerjoin(
Mailbox,
(Mailbox.tenant_id == Tenant.id) & (Mailbox.status != "deleted"),
)
.group_by(Tenant.id)
.order_by(Tenant.created_at.desc())
)
rows = (await self.db.execute(stmt)).all()
result = []
for row in rows:
tenant_obj, user_count, mailbox_count = row
resp = TenantResponse.model_validate(tenant_obj)
resp.user_count = user_count or 0
resp.mailbox_count = mailbox_count or 0
result.append(resp)
return result
async def update_tenant(
self, tenant_id: uuid.UUID, data: TenantUpdateRequest
) -> Tenant:
+38
View File
@@ -0,0 +1,38 @@
-- ============================================================
-- Ruolo PostgreSQL applicativo pechub_app
--
-- Questo script viene eseguito al primo avvio del container DB.
-- Il ruolo viene creato PRIMA che Alembic esegua le migrazioni.
-- I GRANT sulle singole tabelle vengono gestiti dalla migration 0006.
--
-- Scopo:
-- - pechub_app è un utente non-superuser su cui RLS è attiva
-- - Il backend e il worker usano DATABASE_URL con pechub_app
-- - Alembic usa DATABASE_URL_SYNC con l'utente pechub (superuser)
-- ============================================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = 'pechub_app'
) THEN
CREATE USER pechub_app WITH PASSWORD 'pechub_app_password'
NOSUPERUSER NOCREATEDB NOCREATEROLE;
RAISE NOTICE '[pechub] Ruolo pechub_app creato';
ELSE
RAISE NOTICE '[pechub] Ruolo pechub_app già presente skip';
END IF;
END
$$;
-- Permessi di base (i GRANT sulle tabelle vengono dopo le migrazioni)
GRANT CONNECT ON DATABASE pechub TO pechub_app;
GRANT USAGE ON SCHEMA public TO pechub_app;
-- Default privileges: tutte le future tabelle create da pechub
-- ricevono automaticamente i permessi per pechub_app
ALTER DEFAULT PRIVILEGES FOR ROLE pechub IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO pechub_app;
ALTER DEFAULT PRIVILEGES FOR ROLE pechub IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO pechub_app;
+49 -13
View File
@@ -1,17 +1,23 @@
-- ============================================================
-- SEED: Tenant demo + utenti per sviluppo locale
-- SEED: Tenant demo + Tenant acme + utenti per sviluppo locale
--
-- Credenziali:
-- Admin: admin@demo.pechub.it / Demo@PEChub2026!
-- Tenant demo:
-- Admin: admin@demo.pechub.it / Demo@PEChub2026!
-- Operator: operator@demo.pechub.it / Oper@PEChub2026!
--
-- Tenant acme (secondo tenant per test isolamento):
-- Admin: admin@acme.pechub.it / Acme@PEChub2026!
--
-- Super Admin (cross-tenant):
-- superadmin@pechub.it / SuperAdmin@PEChub2026!
--
-- Esegui con: make seed
-- ============================================================
-- Disabilita RLS temporaneamente per il seed
SET session_replication_role = replica;
-- Tenant demo
-- ── Tenant demo ──────────────────────────────────────────────────────────────
INSERT INTO tenants (id, slug, name, plan, is_active, max_mailboxes, max_users)
VALUES (
'11111111-1111-1111-1111-111111111111',
@@ -24,48 +30,75 @@ VALUES (
)
ON CONFLICT (slug) DO NOTHING;
-- Utente super_admin (global, senza tenant specifico usa il tenant demo)
-- ── Tenant acme (secondo tenant per test isolamento) ─────────────────────────
INSERT INTO tenants (id, slug, name, plan, is_active, max_mailboxes, max_users)
VALUES (
'22222222-2222-2222-2222-222222222222',
'acme',
'Acme Corp SpA',
'starter',
TRUE,
5,
10
)
ON CONFLICT (slug) DO NOTHING;
-- ── Utente super_admin (ruolo globale, associato al tenant demo) ──────────────
-- Password: SuperAdmin@PEChub2026! (bcrypt hash)
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
VALUES (
'00000000-0000-0000-0000-000000000001',
'11111111-1111-1111-1111-111111111111',
'superadmin@pechub.it',
'$2b$12$y2yq6X2f3dZi22wqWZd1aumP03IU6OWrrevRMFj9054aGnUms116W', -- SuperAdmin@PEChub2026!
'$2b$12$XVHODc6nahpMm.XO5ifDku77IFDqCcMkJpSc7.uwElpML4wo3gfQu',
'Super Admin PEChub',
'super_admin',
TRUE
)
ON CONFLICT (tenant_id, email) DO NOTHING;
-- Utente admin del tenant demo
-- ── Admin del tenant demo ─────────────────────────────────────────────────────
-- Password: Demo@PEChub2026! (bcrypt hash)
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
VALUES (
'11111111-0000-0000-0000-000000000001',
'11111111-1111-1111-1111-111111111111',
'admin@demo.pechub.it',
'$2b$12$PmyaJvF0i7ACFR39k6hfMO2.6U.FVPYma.7OyXyrGuGuokiJOfX8y', -- Demo@PEChub2026!
'$2b$12$xBbzU5vPAibZWx/jnEwJoO8aAAK9EdIBMzQbo7naD22t37EJeIy9q',
'Admin Demo',
'admin',
TRUE
)
ON CONFLICT (tenant_id, email) DO NOTHING;
-- Utente operator del tenant demo
-- ── Operator del tenant demo ──────────────────────────────────────────────────
-- Password: Oper@PEChub2026! (bcrypt hash)
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
VALUES (
'11111111-0000-0000-0000-000000000002',
'11111111-1111-1111-1111-111111111111',
'operator@demo.pechub.it',
'$2b$12$Z0REc7flPCD3Sb8fZHsuW.Uk2X4JiJO7HhTajNSuPiQgzppkCDmLu', -- Oper@PEChub2026!
'$2b$12$8stUJfKKTB5Tqjrd3Aamm.sOrQe9T0kygbkTbN7raItjhD0exdyVm',
'Operatore Demo',
'operator',
TRUE
)
ON CONFLICT (tenant_id, email) DO NOTHING;
-- ── Admin del tenant acme ─────────────────────────────────────────────────────
-- Password: Acme@PEChub2026! (bcrypt hash)
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
VALUES (
'22222222-0000-0000-0000-000000000001',
'22222222-2222-2222-2222-222222222222',
'admin@acme.pechub.it',
'$2b$12$lGN3ckunwsI2pS2VKPjLAemnvJgv3DzPddcv4W4KzwjeVvRQh.jhO',
'Admin Acme Corp',
'admin',
TRUE
)
ON CONFLICT (tenant_id, email) DO NOTHING;
-- Ripristina RLS
SET session_replication_role = DEFAULT;
@@ -73,8 +106,11 @@ SET session_replication_role = DEFAULT;
DO $$
BEGIN
RAISE NOTICE '✅ Seed completato!';
RAISE NOTICE ' Tenant demo: 11111111-1111-1111-1111-111111111111';
RAISE NOTICE ' Admin: admin@demo.pechub.it / Demo@PEChub2026!';
RAISE NOTICE ' Operator: operator@demo.pechub.it / Oper@PEChub2026!';
RAISE NOTICE ' Tenant demo: 11111111-1111-1111-1111-111111111111';
RAISE NOTICE ' Tenant acme: 22222222-2222-2222-2222-222222222222';
RAISE NOTICE ' SuperAdmin: superadmin@pechub.it / SuperAdmin@PEChub2026!';
RAISE NOTICE ' Admin demo: admin@demo.pechub.it / Demo@PEChub2026!';
RAISE NOTICE ' Operator demo: operator@demo.pechub.it / Oper@PEChub2026!';
RAISE NOTICE ' Admin acme: admin@acme.pechub.it / Acme@PEChub2026!';
END
$$;
+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 {