mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Multitenancy
This commit is contained in:
+12
-3
@@ -1,8 +1,8 @@
|
|||||||
Stiamo lavorando a un PEC Manager SaaS
|
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
|
Non fare commit sul repository GitHub, ci penso io
|
||||||
|
|
||||||
@@ -39,4 +39,13 @@ Ruolo Email Password
|
|||||||
Super Admin superadmin@pechub.it SuperAdmin@PEChub2026!
|
Super Admin superadmin@pechub.it SuperAdmin@PEChub2026!
|
||||||
Admin (tenant demo) admin@demo.pechub.it Demo@PEChub2026!
|
Admin (tenant demo) admin@demo.pechub.it Demo@PEChub2026!
|
||||||
Operator (tenant demo) operator@demo.pechub.it Oper@PEChub2026!
|
Operator (tenant demo) operator@demo.pechub.it Oper@PEChub2026!
|
||||||
Per accedere all'applicazione usa le credenziali Admin del tenant demo.
|
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")
|
||||||
@@ -1,36 +1,44 @@
|
|||||||
"""
|
"""
|
||||||
Router tenant – gestione organizzazioni (solo super_admin).
|
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:
|
Endpoint:
|
||||||
GET /api/v1/tenants → lista tenant
|
GET /api/v1/tenants → lista tenant con statistiche
|
||||||
POST /api/v1/tenants → crea tenant + admin
|
POST /api/v1/tenants → crea tenant + admin
|
||||||
GET /api/v1/tenants/{id} → dettaglio tenant
|
GET /api/v1/tenants/{id} → dettaglio tenant con statistiche
|
||||||
PATCH /api/v1/tenants/{id} → modifica tenant
|
PATCH /api/v1/tenants/{id} → modifica tenant (incluso is_active per sospensione)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
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.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest
|
||||||
from app.services.tenant_service import TenantService
|
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(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
response_model=list[TenantResponse],
|
response_model=list[TenantResponse],
|
||||||
summary="Lista tutti i tenant",
|
summary="Lista tutti i tenant con statistiche",
|
||||||
)
|
)
|
||||||
async def list_tenants(
|
async def list_tenants(
|
||||||
_: SuperAdminUser,
|
_: SuperAdminUser,
|
||||||
db: DB,
|
db: DB,
|
||||||
) -> list[TenantResponse]:
|
) -> list[TenantResponse]:
|
||||||
service = TenantService(db)
|
service = TenantService(db)
|
||||||
tenants = await service.list_tenants()
|
return await service.list_tenants_with_stats()
|
||||||
return [TenantResponse.model_validate(t) for t in tenants]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -52,7 +60,7 @@ async def create_tenant(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/{tenant_id}",
|
"/{tenant_id}",
|
||||||
response_model=TenantResponse,
|
response_model=TenantResponse,
|
||||||
summary="Dettaglio tenant",
|
summary="Dettaglio tenant con statistiche",
|
||||||
)
|
)
|
||||||
async def get_tenant(
|
async def get_tenant(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
@@ -60,14 +68,13 @@ async def get_tenant(
|
|||||||
db: DB,
|
db: DB,
|
||||||
) -> TenantResponse:
|
) -> TenantResponse:
|
||||||
service = TenantService(db)
|
service = TenantService(db)
|
||||||
tenant = await service.get_tenant(tenant_id)
|
return await service.get_tenant_with_stats(tenant_id)
|
||||||
return TenantResponse.model_validate(tenant)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{tenant_id}",
|
"/{tenant_id}",
|
||||||
response_model=TenantResponse,
|
response_model=TenantResponse,
|
||||||
summary="Modifica tenant",
|
summary="Modifica tenant (nome, piano, limiti, sospensione)",
|
||||||
)
|
)
|
||||||
async def update_tenant(
|
async def update_tenant(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ class Settings(BaseSettings):
|
|||||||
minio_bucket: str = "pechub"
|
minio_bucket: str = "pechub"
|
||||||
minio_use_ssl: bool = False
|
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 ──────────────────────────────────────────────────────────────────
|
||||||
cors_origins: str = "http://localhost:3000,http://localhost:5173"
|
cors_origins: str = "http://localhost:3000,http://localhost:5173"
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
class TOTPRequiredError(HTTPException):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
|
|||||||
@@ -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
|
import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
from fastapi import Depends, Header, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.core.security import decode_token
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.models.tenant import Tenant
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ async def get_current_user(
|
|||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
Estrae e valida il JWT dall'header Authorization: Bearer <token>.
|
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
|
token = credentials.credentials
|
||||||
|
|
||||||
@@ -85,6 +87,13 @@ async def get_current_user(
|
|||||||
# Imposta RLS per questo tenant (no-op su SQLite/test)
|
# Imposta RLS per questo tenant (no-op su SQLite/test)
|
||||||
await _set_rls_tenant_id(db, tenant_id)
|
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
|
# Carica utente
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
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
|
from app.core.exceptions import AccountDisabledError
|
||||||
raise 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
|
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 ──────────────────────────────────────────────────────────────
|
# ─── Role guards ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def require_admin(
|
async def require_admin(
|
||||||
@@ -121,8 +153,27 @@ async def require_super_admin(
|
|||||||
return current_user
|
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 ─────────────────────
|
# ─── Tipo annotato per ridurre boilerplate negli endpoint ─────────────────────
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
CurrentTenant = Annotated[Tenant, Depends(get_current_tenant)]
|
||||||
AdminUser = Annotated[User, Depends(require_admin)]
|
AdminUser = Annotated[User, Depends(require_admin)]
|
||||||
SuperAdminUser = Annotated[User, Depends(require_super_admin)]
|
SuperAdminUser = Annotated[User, Depends(require_super_admin)]
|
||||||
DB = Annotated[AsyncSession, Depends(get_db)]
|
DB = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|||||||
@@ -51,4 +51,8 @@ class TenantResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
# Statistiche opzionali (popolate dalla lista)
|
||||||
|
user_count: int = 0
|
||||||
|
mailbox_count: int = 0
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ Servizio tenant – gestione organizzazioni (solo super_admin).
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.exceptions import ConflictError, NotFoundError
|
from app.core.exceptions import ConflictError, NotFoundError
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
|
from app.models.mailbox import Mailbox
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.tenant import TenantCreateRequest, TenantUpdateRequest
|
from app.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest
|
||||||
|
|
||||||
|
|
||||||
class TenantService:
|
class TenantService:
|
||||||
@@ -56,12 +57,72 @@ class TenantService:
|
|||||||
raise NotFoundError("tenant")
|
raise NotFoundError("tenant")
|
||||||
return 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]:
|
async def list_tenants(self) -> list[Tenant]:
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Tenant).order_by(Tenant.created_at.desc())
|
select(Tenant).order_by(Tenant.created_at.desc())
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
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(
|
async def update_tenant(
|
||||||
self, tenant_id: uuid.UUID, data: TenantUpdateRequest
|
self, tenant_id: uuid.UUID, data: TenantUpdateRequest
|
||||||
) -> Tenant:
|
) -> Tenant:
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- SEED: Tenant demo + utenti per sviluppo locale
|
-- SEED: Tenant demo + Tenant acme + utenti per sviluppo locale
|
||||||
--
|
--
|
||||||
-- Credenziali:
|
-- Tenant demo:
|
||||||
-- Admin: admin@demo.pechub.it / Demo@PEChub2026!
|
-- Admin: admin@demo.pechub.it / Demo@PEChub2026!
|
||||||
-- Operator: operator@demo.pechub.it / Oper@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
|
-- Esegui con: make seed
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- Disabilita RLS temporaneamente per il seed
|
-- Disabilita RLS temporaneamente per il seed
|
||||||
SET session_replication_role = replica;
|
SET session_replication_role = replica;
|
||||||
|
|
||||||
-- Tenant demo
|
-- ── Tenant demo ──────────────────────────────────────────────────────────────
|
||||||
INSERT INTO tenants (id, slug, name, plan, is_active, max_mailboxes, max_users)
|
INSERT INTO tenants (id, slug, name, plan, is_active, max_mailboxes, max_users)
|
||||||
VALUES (
|
VALUES (
|
||||||
'11111111-1111-1111-1111-111111111111',
|
'11111111-1111-1111-1111-111111111111',
|
||||||
@@ -24,48 +30,75 @@ VALUES (
|
|||||||
)
|
)
|
||||||
ON CONFLICT (slug) DO NOTHING;
|
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)
|
-- Password: SuperAdmin@PEChub2026! (bcrypt hash)
|
||||||
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
|
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
|
||||||
VALUES (
|
VALUES (
|
||||||
'00000000-0000-0000-0000-000000000001',
|
'00000000-0000-0000-0000-000000000001',
|
||||||
'11111111-1111-1111-1111-111111111111',
|
'11111111-1111-1111-1111-111111111111',
|
||||||
'superadmin@pechub.it',
|
'superadmin@pechub.it',
|
||||||
'$2b$12$y2yq6X2f3dZi22wqWZd1aumP03IU6OWrrevRMFj9054aGnUms116W', -- SuperAdmin@PEChub2026!
|
'$2b$12$XVHODc6nahpMm.XO5ifDku77IFDqCcMkJpSc7.uwElpML4wo3gfQu',
|
||||||
'Super Admin PEChub',
|
'Super Admin PEChub',
|
||||||
'super_admin',
|
'super_admin',
|
||||||
TRUE
|
TRUE
|
||||||
)
|
)
|
||||||
ON CONFLICT (tenant_id, email) DO NOTHING;
|
ON CONFLICT (tenant_id, email) DO NOTHING;
|
||||||
|
|
||||||
-- Utente admin del tenant demo
|
-- ── Admin del tenant demo ─────────────────────────────────────────────────────
|
||||||
-- Password: Demo@PEChub2026! (bcrypt hash)
|
-- Password: Demo@PEChub2026! (bcrypt hash)
|
||||||
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
|
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
|
||||||
VALUES (
|
VALUES (
|
||||||
'11111111-0000-0000-0000-000000000001',
|
'11111111-0000-0000-0000-000000000001',
|
||||||
'11111111-1111-1111-1111-111111111111',
|
'11111111-1111-1111-1111-111111111111',
|
||||||
'admin@demo.pechub.it',
|
'admin@demo.pechub.it',
|
||||||
'$2b$12$PmyaJvF0i7ACFR39k6hfMO2.6U.FVPYma.7OyXyrGuGuokiJOfX8y', -- Demo@PEChub2026!
|
'$2b$12$xBbzU5vPAibZWx/jnEwJoO8aAAK9EdIBMzQbo7naD22t37EJeIy9q',
|
||||||
'Admin Demo',
|
'Admin Demo',
|
||||||
'admin',
|
'admin',
|
||||||
TRUE
|
TRUE
|
||||||
)
|
)
|
||||||
ON CONFLICT (tenant_id, email) DO NOTHING;
|
ON CONFLICT (tenant_id, email) DO NOTHING;
|
||||||
|
|
||||||
-- Utente operator del tenant demo
|
-- ── Operator del tenant demo ──────────────────────────────────────────────────
|
||||||
-- Password: Oper@PEChub2026! (bcrypt hash)
|
-- Password: Oper@PEChub2026! (bcrypt hash)
|
||||||
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
|
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
|
||||||
VALUES (
|
VALUES (
|
||||||
'11111111-0000-0000-0000-000000000002',
|
'11111111-0000-0000-0000-000000000002',
|
||||||
'11111111-1111-1111-1111-111111111111',
|
'11111111-1111-1111-1111-111111111111',
|
||||||
'operator@demo.pechub.it',
|
'operator@demo.pechub.it',
|
||||||
'$2b$12$Z0REc7flPCD3Sb8fZHsuW.Uk2X4JiJO7HhTajNSuPiQgzppkCDmLu', -- Oper@PEChub2026!
|
'$2b$12$8stUJfKKTB5Tqjrd3Aamm.sOrQe9T0kygbkTbN7raItjhD0exdyVm',
|
||||||
'Operatore Demo',
|
'Operatore Demo',
|
||||||
'operator',
|
'operator',
|
||||||
TRUE
|
TRUE
|
||||||
)
|
)
|
||||||
ON CONFLICT (tenant_id, email) DO NOTHING;
|
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
|
-- Ripristina RLS
|
||||||
SET session_replication_role = DEFAULT;
|
SET session_replication_role = DEFAULT;
|
||||||
|
|
||||||
@@ -73,8 +106,11 @@ SET session_replication_role = DEFAULT;
|
|||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
RAISE NOTICE '✅ Seed completato!';
|
RAISE NOTICE '✅ Seed completato!';
|
||||||
RAISE NOTICE ' Tenant demo: 11111111-1111-1111-1111-111111111111';
|
RAISE NOTICE ' Tenant demo: 11111111-1111-1111-1111-111111111111';
|
||||||
RAISE NOTICE ' Admin: admin@demo.pechub.it / Demo@PEChub2026!';
|
RAISE NOTICE ' Tenant acme: 22222222-2222-2222-2222-222222222222';
|
||||||
RAISE NOTICE ' Operator: operator@demo.pechub.it / Oper@PEChub2026!';
|
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
|
END
|
||||||
$$;
|
$$;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PermissionsPage } from '@/pages/Permissions/PermissionsPage'
|
|||||||
import { SettingsPage } from '@/pages/Settings/SettingsPage'
|
import { SettingsPage } from '@/pages/Settings/SettingsPage'
|
||||||
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
|
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
|
||||||
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
|
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
|
||||||
|
import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routing principale dell'applicazione PEChub.
|
* Routing principale dell'applicazione PEChub.
|
||||||
@@ -70,6 +71,9 @@ export default function App() {
|
|||||||
<Route path="/virtual-boxes" element={<VirtualBoxesPage />} />
|
<Route path="/virtual-boxes" element={<VirtualBoxesPage />} />
|
||||||
<Route path="/notifications" element={<NotificationsPage />} />
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
|
|
||||||
|
{/* Super Admin – Gestione Multi-Tenant */}
|
||||||
|
<Route path="/multitenant" element={<MultiTenantPage />} />
|
||||||
|
|
||||||
{/* Profilo utente */}
|
{/* Profilo utente */}
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<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,
|
Bell,
|
||||||
Star,
|
Star,
|
||||||
Archive,
|
Archive,
|
||||||
|
Building2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
@@ -71,7 +72,7 @@ export function Sidebar() {
|
|||||||
/** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */
|
/** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */
|
||||||
const [collapsedVboxes, setCollapsedVboxes] = useState<Set<string>>(new Set())
|
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)
|
const unreadCount = useInboxStore((s) => s.unreadCount)
|
||||||
|
|
||||||
// Le caselle PEC vengono caricate qui e condivise via React Query cache
|
// Le caselle PEC vengono caricate qui e condivise via React Query cache
|
||||||
@@ -387,6 +388,40 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</nav>
|
||||||
|
|
||||||
{/* ── Profilo utente + logout ── */}
|
{/* ── 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 ────────────────────────────────────────────────────────────────────
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user