mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Versamento su API AgID
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
"""Aggiunge la tabella tenant_settings per configurazione archiviazione sostitutiva
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2026-03-19 00:00:00.000000
|
||||||
|
|
||||||
|
Aggiunge:
|
||||||
|
- tenant_settings (configurazione per-tenant: modalità archivio mock/produzione,
|
||||||
|
credenziali conservatore AgID cifrate)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0005"
|
||||||
|
down_revision = "0004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE tenant_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Archiviazione sostitutiva (Fase 6)
|
||||||
|
-- 'mock' → usa conservatore simulato in locale (default sicuro per dev)
|
||||||
|
-- 'production' → usa endpoint reale AgID conservatore
|
||||||
|
archival_mode VARCHAR(20) NOT NULL DEFAULT 'mock',
|
||||||
|
|
||||||
|
-- Identificativo del conservatore (es. 'aruba-cons', 'docuvision', 'mock')
|
||||||
|
conservatore_id VARCHAR(100) NOT NULL DEFAULT 'mock',
|
||||||
|
|
||||||
|
-- URL API conservatore (NULL in modalità mock)
|
||||||
|
conservatore_endpoint TEXT,
|
||||||
|
|
||||||
|
-- Credenziali cifrate AES-256-GCM (NULL in modalità mock)
|
||||||
|
conservatore_username_enc TEXT,
|
||||||
|
conservatore_password_enc TEXT,
|
||||||
|
|
||||||
|
-- Note operative libere
|
||||||
|
archival_notes TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_tenant_settings_tenant UNIQUE (tenant_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("CREATE INDEX idx_tenant_settings_tenant ON tenant_settings (tenant_id)")
|
||||||
|
|
||||||
|
# Trigger updated_at
|
||||||
|
op.execute("""
|
||||||
|
CREATE TRIGGER trg_tenant_settings_updated_at
|
||||||
|
BEFORE UPDATE ON tenant_settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS tenant_settings CASCADE")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Router Impostazioni Tenant (Fase 6).
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
GET /settings → legge le impostazioni del tenant corrente (admin)
|
||||||
|
PUT /settings → aggiorna le impostazioni del tenant corrente (admin)
|
||||||
|
|
||||||
|
Solo gli admin e super_admin possono accedere.
|
||||||
|
La sezione "archiviazione" gestisce il toggle mock/produzione per il
|
||||||
|
conservatore AgID (Fase 6 – Archiviazione Sostitutiva).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.dependencies import AdminUser, DB
|
||||||
|
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
|
||||||
|
from app.services.tenant_settings_service import TenantSettingsService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["Impostazioni"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=TenantSettingsResponse,
|
||||||
|
summary="Legge le impostazioni del tenant",
|
||||||
|
description=(
|
||||||
|
"Restituisce la configurazione operativa del tenant: "
|
||||||
|
"modalità archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_settings(
|
||||||
|
current_user: AdminUser,
|
||||||
|
db: DB,
|
||||||
|
) -> TenantSettingsResponse:
|
||||||
|
service = TenantSettingsService(db)
|
||||||
|
settings = await service.get_or_create(current_user.tenant_id)
|
||||||
|
return TenantSettingsService.to_response(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"",
|
||||||
|
response_model=TenantSettingsResponse,
|
||||||
|
summary="Aggiorna le impostazioni del tenant",
|
||||||
|
description=(
|
||||||
|
"Aggiorna la configurazione operativa del tenant. "
|
||||||
|
"Tutti i campi sono opzionali (semantica PATCH). "
|
||||||
|
"Il passaggio a modalità 'production' richiede un endpoint conservatore configurato."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def update_settings(
|
||||||
|
body: TenantSettingsUpdate,
|
||||||
|
current_user: AdminUser,
|
||||||
|
db: DB,
|
||||||
|
) -> TenantSettingsResponse:
|
||||||
|
service = TenantSettingsService(db)
|
||||||
|
settings = await service.update(current_user.tenant_id, body)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(settings)
|
||||||
|
return TenantSettingsService.to_response(settings)
|
||||||
@@ -121,3 +121,11 @@ class ValidationError(HTTPException):
|
|||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=detail,
|
detail=detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BadRequestError(HTTPException):
|
||||||
|
def __init__(self, detail: str = "Richiesta non valida") -> None:
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from slowapi.middleware import SlowAPIMiddleware
|
|||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
|
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
|
||||||
|
from app.api.v1 import settings as settings_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.logging import get_logger, setup_logging
|
from app.core.logging import get_logger, setup_logging
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
@@ -94,6 +95,7 @@ app.include_router(ws.router, prefix=API_PREFIX)
|
|||||||
app.include_router(virtual_boxes.router, prefix=API_PREFIX)
|
app.include_router(virtual_boxes.router, prefix=API_PREFIX)
|
||||||
app.include_router(notifications.router, prefix=API_PREFIX)
|
app.include_router(notifications.router, prefix=API_PREFIX)
|
||||||
app.include_router(labels.router, prefix=API_PREFIX)
|
app.include_router(labels.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(settings_router.router, prefix=API_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
# ─── Health check ─────────────────────────────────────────────────────────────
|
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ from app.models.label import Label, MessageLabel # noqa: F401
|
|||||||
from app.models.permission import MailboxPermission # noqa: F401
|
from app.models.permission import MailboxPermission # noqa: F401
|
||||||
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
|
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
|
||||||
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
|
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
|
||||||
|
from app.models.tenant_settings import TenantSettings # noqa: F401
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Modello TenantSettings – configurazione per-tenant (archiviazione sostitutiva, ecc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSettings(Base):
|
||||||
|
"""
|
||||||
|
Configurazione operativa per tenant.
|
||||||
|
|
||||||
|
Attualmente gestisce:
|
||||||
|
- Modalità archiviazione sostitutiva (mock / production)
|
||||||
|
- Credenziali conservatore AgID (cifrate AES-256-GCM)
|
||||||
|
|
||||||
|
Una riga per tenant, creata al primo accesso (upsert).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tenant_settings"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Archiviazione sostitutiva ─────────────────────────────────────────────
|
||||||
|
# 'mock' → conservatore simulato (risposte false, default per dev)
|
||||||
|
# 'production' → endpoint reale AgID conservatore
|
||||||
|
archival_mode: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Identificativo conservatore (es. 'aruba-cons', 'docuvision', 'mock')
|
||||||
|
conservatore_id: Mapped[str] = mapped_column(
|
||||||
|
String(100), nullable=False, default="mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
# URL endpoint API conservatore (None in modalità mock)
|
||||||
|
conservatore_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Credenziali cifrate AES-256-GCM (None in modalità mock)
|
||||||
|
conservatore_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
conservatore_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Note operative opzionali
|
||||||
|
archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", name="uq_tenant_settings_tenant"),
|
||||||
|
Index("idx_tenant_settings_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TenantSettings tenant={self.tenant_id} mode={self.archival_mode!r}>"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Schema Pydantic per TenantSettings – lettura e aggiornamento impostazioni tenant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
ArchivalMode = Literal["mock", "production"]
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSettingsResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Risposta GET /settings.
|
||||||
|
|
||||||
|
Le credenziali del conservatore non vengono mai esposte in chiaro:
|
||||||
|
vengono restituite come flag booleani (is_configured).
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
|
||||||
|
# Archiviazione
|
||||||
|
archival_mode: ArchivalMode
|
||||||
|
conservatore_id: str
|
||||||
|
conservatore_endpoint: str | None
|
||||||
|
conservatore_username_configured: bool # TRUE se la username è già salvata
|
||||||
|
conservatore_password_configured: bool # TRUE se la password è già salvata
|
||||||
|
archival_notes: str | None
|
||||||
|
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSettingsUpdate(BaseModel):
|
||||||
|
"""
|
||||||
|
Body PUT /settings.
|
||||||
|
|
||||||
|
Tutti i campi sono opzionali (PATCH semantics).
|
||||||
|
Le credenziali vengono aggiornate solo se fornite.
|
||||||
|
Un valore esplicitamente None può essere usato per cancellare le credenziali.
|
||||||
|
"""
|
||||||
|
|
||||||
|
archival_mode: ArchivalMode | None = None
|
||||||
|
|
||||||
|
conservatore_id: str | None = Field(
|
||||||
|
default=None, min_length=1, max_length=100
|
||||||
|
)
|
||||||
|
|
||||||
|
# URL endpoint del conservatore (obbligatorio in produzione, ignorato in mock)
|
||||||
|
conservatore_endpoint: str | None = None
|
||||||
|
|
||||||
|
# Credenziali in chiaro: vengono cifrate prima del salvataggio.
|
||||||
|
# Valore stringa vuota ("") = cancella la credenziale.
|
||||||
|
conservatore_username: str | None = None
|
||||||
|
conservatore_password: str | None = None
|
||||||
|
|
||||||
|
archival_notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("archival_mode")
|
||||||
|
@classmethod
|
||||||
|
def validate_archival_mode(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and v not in ("mock", "production"):
|
||||||
|
raise ValueError("archival_mode deve essere 'mock' o 'production'")
|
||||||
|
return v
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Servizio TenantSettings – lettura e aggiornamento configurazione per-tenant.
|
||||||
|
|
||||||
|
Responsabilità:
|
||||||
|
- Upsert della riga settings (crea se non esiste, aggiorna altrimenti)
|
||||||
|
- Cifratura/decifratura AES-256-GCM delle credenziali conservatore
|
||||||
|
- Validazione di consistenza (produzione richiede endpoint + credenziali)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
from app.core.security import decrypt_credential, encrypt_credential
|
||||||
|
from app.models.tenant_settings import TenantSettings
|
||||||
|
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSettingsService:
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
# ─── Lettura (o creazione defaults) ──────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_or_create(self, tenant_id: uuid.UUID) -> TenantSettings:
|
||||||
|
"""
|
||||||
|
Restituisce le impostazioni del tenant.
|
||||||
|
Se non esistono, crea una riga con i valori di default (mock).
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
settings = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if settings is None:
|
||||||
|
settings = TenantSettings(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
archival_mode="mock",
|
||||||
|
conservatore_id="mock",
|
||||||
|
)
|
||||||
|
self.db.add(settings)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
# ─── Aggiornamento ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
data: TenantSettingsUpdate,
|
||||||
|
) -> TenantSettings:
|
||||||
|
"""
|
||||||
|
Aggiornamento parziale delle impostazioni.
|
||||||
|
|
||||||
|
Regole:
|
||||||
|
- Il passaggio a 'production' è consentito solo se conservatore_endpoint
|
||||||
|
è già configurato oppure viene fornito nel body corrente.
|
||||||
|
- Le credenziali vengono cifrate prima del salvataggio.
|
||||||
|
- Una stringa vuota "" su username/password cancella la credenziale.
|
||||||
|
"""
|
||||||
|
settings = await self.get_or_create(tenant_id)
|
||||||
|
|
||||||
|
# Applica aggiornamenti campi non-credenziali
|
||||||
|
if data.archival_mode is not None:
|
||||||
|
settings.archival_mode = data.archival_mode
|
||||||
|
|
||||||
|
if data.conservatore_id is not None:
|
||||||
|
settings.conservatore_id = data.conservatore_id
|
||||||
|
|
||||||
|
if data.conservatore_endpoint is not None:
|
||||||
|
settings.conservatore_endpoint = data.conservatore_endpoint or None
|
||||||
|
|
||||||
|
if data.archival_notes is not None:
|
||||||
|
settings.archival_notes = data.archival_notes or None
|
||||||
|
|
||||||
|
# Aggiorna credenziali (cifra se fornite, cancella se stringa vuota)
|
||||||
|
if data.conservatore_username is not None:
|
||||||
|
if data.conservatore_username == "":
|
||||||
|
settings.conservatore_username_enc = None
|
||||||
|
else:
|
||||||
|
settings.conservatore_username_enc = encrypt_credential(
|
||||||
|
data.conservatore_username
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.conservatore_password is not None:
|
||||||
|
if data.conservatore_password == "":
|
||||||
|
settings.conservatore_password_enc = None
|
||||||
|
else:
|
||||||
|
settings.conservatore_password_enc = encrypt_credential(
|
||||||
|
data.conservatore_password
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validazione: produzione richiede endpoint configurato
|
||||||
|
if settings.archival_mode == "production":
|
||||||
|
if not settings.conservatore_endpoint:
|
||||||
|
raise BadRequestError(
|
||||||
|
"La modalità produzione richiede un endpoint conservatore valido. "
|
||||||
|
"Inserisci l'URL prima di attivare la modalità produzione."
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
# ─── Costruzione response ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_response(settings: TenantSettings) -> TenantSettingsResponse:
|
||||||
|
"""
|
||||||
|
Converte il modello in DTO di risposta.
|
||||||
|
Le credenziali NON vengono mai esposte in chiaro.
|
||||||
|
"""
|
||||||
|
return TenantSettingsResponse(
|
||||||
|
id=settings.id,
|
||||||
|
tenant_id=settings.tenant_id,
|
||||||
|
archival_mode=settings.archival_mode, # type: ignore[arg-type]
|
||||||
|
conservatore_id=settings.conservatore_id,
|
||||||
|
conservatore_endpoint=settings.conservatore_endpoint,
|
||||||
|
conservatore_username_configured=settings.conservatore_username_enc is not None,
|
||||||
|
conservatore_password_configured=settings.conservatore_password_enc is not None,
|
||||||
|
archival_notes=settings.archival_notes,
|
||||||
|
created_at=settings.created_at,
|
||||||
|
updated_at=settings.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Helper per il worker (recupera credenziali decifrate) ───────────────
|
||||||
|
|
||||||
|
async def get_conservatore_credentials(
|
||||||
|
self, tenant_id: uuid.UUID
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Restituisce le credenziali del conservatore decifrate.
|
||||||
|
Usato dal worker per effettuare versamenti.
|
||||||
|
"""
|
||||||
|
settings = await self.get_or_create(tenant_id)
|
||||||
|
return {
|
||||||
|
"mode": settings.archival_mode,
|
||||||
|
"conservatore_id": settings.conservatore_id,
|
||||||
|
"endpoint": settings.conservatore_endpoint,
|
||||||
|
"username": (
|
||||||
|
decrypt_credential(settings.conservatore_username_enc)
|
||||||
|
if settings.conservatore_username_enc
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"password": (
|
||||||
|
decrypt_credential(settings.conservatore_password_enc)
|
||||||
|
if settings.conservatore_password_enc
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* API client per le impostazioni del tenant.
|
||||||
|
*
|
||||||
|
* Endpoint:
|
||||||
|
* GET /api/v1/settings → legge configurazione (admin)
|
||||||
|
* PUT /api/v1/settings → aggiorna configurazione (admin)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
// ─── Tipi ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ArchivalMode = 'mock' | 'production'
|
||||||
|
|
||||||
|
export interface TenantSettingsResponse {
|
||||||
|
id: string
|
||||||
|
tenant_id: string
|
||||||
|
archival_mode: ArchivalMode
|
||||||
|
conservatore_id: string
|
||||||
|
conservatore_endpoint: string | null
|
||||||
|
conservatore_username_configured: boolean
|
||||||
|
conservatore_password_configured: boolean
|
||||||
|
archival_notes: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantSettingsUpdate {
|
||||||
|
archival_mode?: ArchivalMode
|
||||||
|
conservatore_id?: string
|
||||||
|
/** URL endpoint API del conservatore (obbligatorio in produzione) */
|
||||||
|
conservatore_endpoint?: string
|
||||||
|
/** Username in chiaro – viene cifrata lato server. Stringa vuota = cancella */
|
||||||
|
conservatore_username?: string
|
||||||
|
/** Password in chiaro – viene cifrata lato server. Stringa vuota = cancella */
|
||||||
|
conservatore_password?: string
|
||||||
|
archival_notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Client ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const settingsApi = {
|
||||||
|
/**
|
||||||
|
* Recupera le impostazioni del tenant corrente.
|
||||||
|
* Se non esistono, il backend le crea con i valori di default (mock).
|
||||||
|
*/
|
||||||
|
get: async (): Promise<TenantSettingsResponse> => {
|
||||||
|
const { data } = await apiClient.get<TenantSettingsResponse>('/settings')
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna le impostazioni del tenant.
|
||||||
|
* Solo i campi forniti vengono modificati (semantica PATCH).
|
||||||
|
*/
|
||||||
|
update: async (payload: TenantSettingsUpdate): Promise<TenantSettingsResponse> => {
|
||||||
|
const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,17 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* SettingsPage – impostazioni profilo dell'utente corrente.
|
* SettingsPage – impostazioni profilo utente e configurazione tenant (admin).
|
||||||
*
|
*
|
||||||
* Sezioni:
|
* Sezioni:
|
||||||
* - Informazioni profilo (nome visualizzato, email, ruolo)
|
* - Informazioni profilo (nome visualizzato, email, ruolo)
|
||||||
* - Modifica nome
|
* - Modifica nome
|
||||||
* - Cambio password
|
* - Cambio password
|
||||||
|
* - [Solo admin] Archiviazione Sostitutiva – toggle mock/produzione + credenziali conservatore
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Settings, User, Lock, Save } from 'lucide-react'
|
import {
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
Lock,
|
||||||
|
Save,
|
||||||
|
Archive,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FlaskConical,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useAuthStore } from '@/store/auth.store'
|
import { useAuthStore } from '@/store/auth.store'
|
||||||
import { usersApi } from '@/api/users.api'
|
import { usersApi } from '@/api/users.api'
|
||||||
|
import { settingsApi, type TenantSettingsResponse, type ArchivalMode } from '@/api/settings.api'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
@@ -33,11 +49,31 @@ function roleLabel(role: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Badge modalità archiviazione ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ArchivalModeBadge({ mode }: { mode: ArchivalMode }) {
|
||||||
|
if (mode === 'production') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
Produzione
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
|
||||||
|
<FlaskConical className="h-3 w-3" />
|
||||||
|
Mock (simulato)
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Pagina ──────────────────────────────────────────────────────────────────
|
// ─── Pagina ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const loadUser = useAuthStore((s) => s.loadUser)
|
const loadUser = useAuthStore((s) => s.loadUser)
|
||||||
|
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'
|
||||||
|
|
||||||
/* ── Stato modifica nome ── */
|
/* ── Stato modifica nome ── */
|
||||||
const [fullName, setFullName] = useState(user?.full_name ?? '')
|
const [fullName, setFullName] = useState(user?.full_name ?? '')
|
||||||
@@ -48,6 +84,49 @@ export function SettingsPage() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [savingPwd, setSavingPwd] = useState(false)
|
const [savingPwd, setSavingPwd] = useState(false)
|
||||||
|
|
||||||
|
/* ── Stato impostazioni archiviazione (admin) ── */
|
||||||
|
const [archivalSettings, setArchivalSettings] = useState<TenantSettingsResponse | null>(null)
|
||||||
|
const [loadingArchival, setLoadingArchival] = useState(false)
|
||||||
|
const [archivalExpanded, setArchivalExpanded] = useState(false)
|
||||||
|
|
||||||
|
// Form archiviazione
|
||||||
|
const [archivalMode, setArchivalMode] = useState<ArchivalMode>('mock')
|
||||||
|
const [conservatoreId, setConservatoreId] = useState('')
|
||||||
|
const [conservatoreEndpoint, setConservatoreEndpoint] = useState('')
|
||||||
|
const [conservatoreUsername, setConservatoreUsername] = useState('')
|
||||||
|
const [conservatorePassword, setConservatorePassword] = useState('')
|
||||||
|
const [archivalNotes, setArchivalNotes] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [savingArchival, setSavingArchival] = useState(false)
|
||||||
|
|
||||||
|
// Conferma passaggio a produzione
|
||||||
|
const [showProductionConfirm, setShowProductionConfirm] = useState(false)
|
||||||
|
|
||||||
|
/* ── Carica impostazioni archiviazione ── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) {
|
||||||
|
loadArchivalSettings()
|
||||||
|
}
|
||||||
|
}, [isAdmin])
|
||||||
|
|
||||||
|
const loadArchivalSettings = async () => {
|
||||||
|
setLoadingArchival(true)
|
||||||
|
try {
|
||||||
|
const data = await settingsApi.get()
|
||||||
|
setArchivalSettings(data)
|
||||||
|
// Popola form
|
||||||
|
setArchivalMode(data.archival_mode)
|
||||||
|
setConservatoreId(data.conservatore_id)
|
||||||
|
setConservatoreEndpoint(data.conservatore_endpoint ?? '')
|
||||||
|
setArchivalNotes(data.archival_notes ?? '')
|
||||||
|
// Username/password non vengono mai restituiti in chiaro
|
||||||
|
} catch {
|
||||||
|
toast.error('Errore durante il caricamento delle impostazioni di archiviazione')
|
||||||
|
} finally {
|
||||||
|
setLoadingArchival(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Salva nome ── */
|
/* ── Salva nome ── */
|
||||||
const handleSaveName = async () => {
|
const handleSaveName = async () => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
@@ -91,6 +170,67 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Cambio modalità archiviazione ── */
|
||||||
|
const handleModeToggle = (newMode: ArchivalMode) => {
|
||||||
|
if (newMode === 'production' && archivalMode === 'mock') {
|
||||||
|
// Chiedi conferma prima di passare a produzione
|
||||||
|
setShowProductionConfirm(true)
|
||||||
|
} else {
|
||||||
|
setArchivalMode(newMode)
|
||||||
|
setShowProductionConfirm(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Salva impostazioni archiviazione ── */
|
||||||
|
const handleSaveArchival = async () => {
|
||||||
|
// Validazione client-side per modalità produzione
|
||||||
|
if (archivalMode === 'production' && !conservatoreEndpoint.trim()) {
|
||||||
|
toast.error('La modalità produzione richiede un URL endpoint del conservatore')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingArchival(true)
|
||||||
|
try {
|
||||||
|
const payload: Parameters<typeof settingsApi.update>[0] = {
|
||||||
|
archival_mode: archivalMode,
|
||||||
|
conservatore_id: conservatoreId || undefined,
|
||||||
|
conservatore_endpoint: conservatoreEndpoint || undefined,
|
||||||
|
archival_notes: archivalNotes || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Includi credenziali solo se l'utente ha inserito qualcosa
|
||||||
|
if (conservatoreUsername) {
|
||||||
|
payload.conservatore_username = conservatoreUsername
|
||||||
|
}
|
||||||
|
if (conservatorePassword) {
|
||||||
|
payload.conservatore_password = conservatorePassword
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await settingsApi.update(payload)
|
||||||
|
setArchivalSettings(updated)
|
||||||
|
setArchivalMode(updated.archival_mode)
|
||||||
|
setConservatoreId(updated.conservatore_id)
|
||||||
|
setConservatoreEndpoint(updated.conservatore_endpoint ?? '')
|
||||||
|
setArchivalNotes(updated.archival_notes ?? '')
|
||||||
|
// Reset credenziali (non rimostrare in chiaro)
|
||||||
|
setConservatoreUsername('')
|
||||||
|
setConservatorePassword('')
|
||||||
|
setShowProductionConfirm(false)
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
updated.archival_mode === 'production'
|
||||||
|
? '✅ Archiviazione attivata in modalità PRODUZIONE'
|
||||||
|
: '🧪 Archiviazione impostata in modalità mock'
|
||||||
|
)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { detail?: string } } })
|
||||||
|
?.response?.data?.detail
|
||||||
|
toast.error(msg ?? 'Errore durante il salvataggio delle impostazioni')
|
||||||
|
} finally {
|
||||||
|
setSavingArchival(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -193,6 +333,319 @@ export function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Card: Archiviazione Sostitutiva (solo admin) ── */}
|
||||||
|
{isAdmin && (
|
||||||
|
<Card className="p-5">
|
||||||
|
{/* Header collassabile */}
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between text-left"
|
||||||
|
onClick={() => setArchivalExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Archive className="h-4 w-4 text-gray-500" />
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
|
Archiviazione Sostitutiva
|
||||||
|
</h2>
|
||||||
|
{archivalSettings && (
|
||||||
|
<ArchivalModeBadge mode={archivalSettings.archival_mode} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{archivalExpanded
|
||||||
|
? <ChevronUp className="h-4 w-4 text-gray-400" />
|
||||||
|
: <ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{archivalExpanded && (
|
||||||
|
<div className="mt-5 space-y-5">
|
||||||
|
|
||||||
|
{loadingArchival ? (
|
||||||
|
<p className="text-sm text-gray-500 py-4 text-center">
|
||||||
|
Caricamento impostazioni…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── Toggle modalità ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Modalità conservatore</Label>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
In modalità <strong>mock</strong> le operazioni di versamento vengono
|
||||||
|
simulate localmente senza inviare dati a sistemi esterni. Attiva la
|
||||||
|
modalità <strong>produzione</strong> solo dopo aver configurato l'endpoint
|
||||||
|
e le credenziali del conservatore AgID.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
{/* Bottone Mock */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleModeToggle('mock')}
|
||||||
|
className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
||||||
|
archivalMode === 'mock'
|
||||||
|
? 'border-amber-400 bg-amber-50 text-amber-900'
|
||||||
|
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FlaskConical className={`h-6 w-6 ${archivalMode === 'mock' ? 'text-amber-600' : 'text-gray-400'}`} />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold">Mock</p>
|
||||||
|
<p className="text-xs opacity-75">Simulazione locale</p>
|
||||||
|
</div>
|
||||||
|
{archivalMode === 'mock' && (
|
||||||
|
<CheckCircle className="h-4 w-4 text-amber-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bottone Produzione */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleModeToggle('production')}
|
||||||
|
className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
||||||
|
archivalMode === 'production'
|
||||||
|
? 'border-green-500 bg-green-50 text-green-900'
|
||||||
|
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Zap className={`h-6 w-6 ${archivalMode === 'production' ? 'text-green-600' : 'text-gray-400'}`} />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold">Produzione</p>
|
||||||
|
<p className="text-xs opacity-75">Conservatore reale AgID</p>
|
||||||
|
</div>
|
||||||
|
{archivalMode === 'production' && (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banner conferma passaggio a produzione */}
|
||||||
|
{showProductionConfirm && archivalMode === 'mock' && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-amber-50 border border-amber-200 flex items-start gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-800">
|
||||||
|
Stai per attivare la modalità produzione
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-700 mt-0.5">
|
||||||
|
I versamenti verranno inviati al conservatore AgID reale.
|
||||||
|
Assicurati che l'endpoint e le credenziali siano corretti.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
className="text-xs font-medium text-amber-800 underline hover:no-underline"
|
||||||
|
onClick={() => {
|
||||||
|
setArchivalMode('production')
|
||||||
|
setShowProductionConfirm(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confermo, attiva produzione
|
||||||
|
</button>
|
||||||
|
<span className="text-amber-600">·</span>
|
||||||
|
<button
|
||||||
|
className="text-xs font-medium text-gray-600 underline hover:no-underline"
|
||||||
|
onClick={() => setShowProductionConfirm(false)}
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-gray-100" />
|
||||||
|
|
||||||
|
{/* ── Identificativo conservatore ── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="conservatore_id">Identificativo conservatore</Label>
|
||||||
|
<Input
|
||||||
|
id="conservatore_id"
|
||||||
|
value={conservatoreId}
|
||||||
|
onChange={(e) => setConservatoreId(e.target.value)}
|
||||||
|
placeholder="es. aruba-cons, docuvision, namirial"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Codice identificativo del provider di conservazione (usato nei log dei versamenti).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Endpoint (mostrato sempre, obbligatorio in produzione) ── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="conservatore_endpoint">
|
||||||
|
URL endpoint API conservatore
|
||||||
|
{archivalMode === 'production' && (
|
||||||
|
<span className="ml-1 text-red-500">*</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="conservatore_endpoint"
|
||||||
|
value={conservatoreEndpoint}
|
||||||
|
onChange={(e) => setConservatoreEndpoint(e.target.value)}
|
||||||
|
placeholder="https://conservatore.provider.it/api/v1"
|
||||||
|
className={
|
||||||
|
archivalMode === 'production' && !conservatoreEndpoint
|
||||||
|
? 'border-red-300 focus:ring-red-400'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{archivalMode === 'mock' && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Non utilizzato in modalità mock – puoi configurarlo in anticipo.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{archivalMode === 'production' && !conservatoreEndpoint && (
|
||||||
|
<p className="text-xs text-red-500">
|
||||||
|
Obbligatorio per la modalità produzione
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Credenziali (mostrate sempre, più evidenti in produzione) ── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">
|
||||||
|
Credenziali conservatore
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Vengono salvate cifrate (AES-256-GCM).
|
||||||
|
{archivalSettings?.conservatore_username_configured && (
|
||||||
|
<span className="ml-1 text-green-600 font-medium">
|
||||||
|
✓ Username configurata
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{archivalSettings?.conservatore_password_configured && (
|
||||||
|
<span className="ml-2 text-green-600 font-medium">
|
||||||
|
✓ Password configurata
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="conservatore_username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="conservatore_username"
|
||||||
|
value={conservatoreUsername}
|
||||||
|
onChange={(e) => setConservatoreUsername(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
archivalSettings?.conservatore_username_configured
|
||||||
|
? '(credenziale già salvata – lascia vuoto per mantenerla)'
|
||||||
|
: 'Inserisci username conservatore'
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="conservatore_password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="conservatore_password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={conservatorePassword}
|
||||||
|
onChange={(e) => setConservatorePassword(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
archivalSettings?.conservatore_password_configured
|
||||||
|
? '(credenziale già salvata – lascia vuoto per mantenerla)'
|
||||||
|
: 'Inserisci password conservatore'
|
||||||
|
}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
>
|
||||||
|
{showPassword
|
||||||
|
? <EyeOff className="h-4 w-4" />
|
||||||
|
: <Eye className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Note operative ── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="archival_notes">Note operative (opzionale)</Label>
|
||||||
|
<textarea
|
||||||
|
id="archival_notes"
|
||||||
|
value={archivalNotes}
|
||||||
|
onChange={(e) => setArchivalNotes(e.target.value)}
|
||||||
|
placeholder="es. Contratto n. 123/2026 – scadenza 31/12/2027"
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Riepilogo stato corrente ── */}
|
||||||
|
{archivalSettings && (
|
||||||
|
<div className={`rounded-lg p-3 text-xs border ${
|
||||||
|
archivalSettings.archival_mode === 'production'
|
||||||
|
? 'bg-green-50 border-green-200 text-green-800'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
<p className="font-medium mb-1">
|
||||||
|
Configurazione attuale (salvata)
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
<li>
|
||||||
|
Modalità:{' '}
|
||||||
|
<strong>
|
||||||
|
{archivalSettings.archival_mode === 'production'
|
||||||
|
? 'Produzione'
|
||||||
|
: 'Mock (simulazione)'}
|
||||||
|
</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Conservatore ID: <strong>{archivalSettings.conservatore_id}</strong>
|
||||||
|
</li>
|
||||||
|
{archivalSettings.conservatore_endpoint && (
|
||||||
|
<li>
|
||||||
|
Endpoint:{' '}
|
||||||
|
<strong className="font-mono text-xs break-all">
|
||||||
|
{archivalSettings.conservatore_endpoint}
|
||||||
|
</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
Credenziali:{' '}
|
||||||
|
{archivalSettings.conservatore_username_configured &&
|
||||||
|
archivalSettings.conservatore_password_configured
|
||||||
|
? <strong className="text-green-700">Configurate ✓</strong>
|
||||||
|
: <span className="text-gray-500">Non configurate</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pulsante salva ── */}
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveArchival}
|
||||||
|
disabled={savingArchival}
|
||||||
|
className={
|
||||||
|
archivalMode === 'production'
|
||||||
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4 mr-1.5" />
|
||||||
|
{savingArchival
|
||||||
|
? 'Salvataggio…'
|
||||||
|
: archivalMode === 'production'
|
||||||
|
? 'Salva e attiva produzione'
|
||||||
|
: 'Salva impostazioni'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Modulo archiviazione sostitutiva (Fase 6)
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""
|
||||||
|
Client conservatore AgID – supporta modalità mock e produzione.
|
||||||
|
|
||||||
|
Modalità mock (default in sviluppo):
|
||||||
|
- Simula localmente tutte le chiamate al conservatore
|
||||||
|
- Restituisce risposte sintetiche plausibili (RdV false)
|
||||||
|
- Non effettua alcuna chiamata di rete esterna
|
||||||
|
- Utile per sviluppo, test e demo
|
||||||
|
|
||||||
|
Modalità produzione:
|
||||||
|
- Esegue chiamate HTTP reali all'endpoint AgID del conservatore configurato
|
||||||
|
- Usa le credenziali cifrate recuperate dalle impostazioni del tenant
|
||||||
|
- Autenticazione HTTP Basic (standard AgID per versamenti SIP)
|
||||||
|
|
||||||
|
Come switchare da mock a produzione:
|
||||||
|
L'admin del tenant configura la modalità dalla pagina Impostazioni del
|
||||||
|
frontend → sezione "Archiviazione Sostitutiva".
|
||||||
|
Le credenziali vengono salvate cifrate nel DB (AES-256-GCM, ADR-002).
|
||||||
|
Il worker legge la configurazione a runtime dalla tabella tenant_settings.
|
||||||
|
|
||||||
|
Interfaccia pubblica (stessa per entrambe le modalità):
|
||||||
|
client = ConservatoreClient.from_tenant_credentials(creds)
|
||||||
|
result = await client.upload_versamento(sip_path, sip_bytes)
|
||||||
|
status = await client.get_versamento_status(versamento_id)
|
||||||
|
dip = await client.get_dip(versamento_id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import get_settings # noqa: F401 (futuro uso logger)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── DTO risultati ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VersamentoResult:
|
||||||
|
"""Risultato dell'upload di un pacchetto SIP al conservatore."""
|
||||||
|
success: bool
|
||||||
|
versamento_id: str | None = None
|
||||||
|
message: str = ""
|
||||||
|
raw_response: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VersamentoStatus:
|
||||||
|
"""Stato di un versamento in corso o completato."""
|
||||||
|
versamento_id: str
|
||||||
|
status: str # pending | processing | accepted | rejected
|
||||||
|
message: str = ""
|
||||||
|
rdv_available: bool = False
|
||||||
|
raw_response: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DipResult:
|
||||||
|
"""Risultato di una richiesta DIP (Dissemination Information Package)."""
|
||||||
|
success: bool
|
||||||
|
dip_id: str | None = None
|
||||||
|
download_url: str | None = None
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Client base (interfaccia) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _BaseConservatoreClient:
|
||||||
|
"""Interfaccia comune per mock e produzione."""
|
||||||
|
|
||||||
|
async def upload_versamento(
|
||||||
|
self,
|
||||||
|
sip_path: str,
|
||||||
|
sip_bytes: bytes,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> VersamentoResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_versamento_status(
|
||||||
|
self, versamento_id: str
|
||||||
|
) -> VersamentoStatus:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_dip(self, versamento_id: str) -> DipResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Implementazione MOCK ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MockConservatoreClient(_BaseConservatoreClient):
|
||||||
|
"""
|
||||||
|
Conservatore simulato – nessuna chiamata di rete.
|
||||||
|
|
||||||
|
Simula tempi realistici e risponde con dati fittizi ma strutturalmente
|
||||||
|
corretti, in modo che il resto del codice possa essere testato end-to-end
|
||||||
|
senza dipendenze esterne.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def upload_versamento(
|
||||||
|
self,
|
||||||
|
sip_path: str,
|
||||||
|
sip_bytes: bytes,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> VersamentoResult:
|
||||||
|
"""Simula upload SIP: genera un versamento_id deterministico."""
|
||||||
|
checksum = hashlib.sha256(sip_bytes).hexdigest()[:16]
|
||||||
|
tenant_prefix = str(tenant_id)[:8]
|
||||||
|
versamento_id = f"MOCK-{tenant_prefix}-{checksum}"
|
||||||
|
|
||||||
|
return VersamentoResult(
|
||||||
|
success=True,
|
||||||
|
versamento_id=versamento_id,
|
||||||
|
message="[MOCK] Versamento accettato dal conservatore simulato",
|
||||||
|
raw_response={
|
||||||
|
"versamento_id": versamento_id,
|
||||||
|
"stato": "accepted",
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"mock": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_versamento_status(
|
||||||
|
self, versamento_id: str
|
||||||
|
) -> VersamentoStatus:
|
||||||
|
"""Simula polling stato: risponde sempre 'accepted' con RdV disponibile."""
|
||||||
|
return VersamentoStatus(
|
||||||
|
versamento_id=versamento_id,
|
||||||
|
status="accepted",
|
||||||
|
message="[MOCK] Versamento confermato",
|
||||||
|
rdv_available=True,
|
||||||
|
raw_response={
|
||||||
|
"versamento_id": versamento_id,
|
||||||
|
"stato": "accepted",
|
||||||
|
"rdv_disponibile": True,
|
||||||
|
"mock": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_dip(self, versamento_id: str) -> DipResult:
|
||||||
|
"""Simula richiesta DIP."""
|
||||||
|
dip_id = f"DIP-{versamento_id}"
|
||||||
|
return DipResult(
|
||||||
|
success=True,
|
||||||
|
dip_id=dip_id,
|
||||||
|
download_url=f"http://mock-conservatore.local/dip/{dip_id}",
|
||||||
|
message="[MOCK] DIP disponibile",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Implementazione PRODUZIONE ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ProductionConservatoreClient(_BaseConservatoreClient):
|
||||||
|
"""
|
||||||
|
Client HTTP reale per conservatore AgID.
|
||||||
|
|
||||||
|
Autenticazione: HTTP Basic (standard AgID CNIPA).
|
||||||
|
Formato SIP: UNI SInCRO 11386:2023 (pacchetto ZIP con indice XML).
|
||||||
|
|
||||||
|
L'URL endpoint e le credenziali arrivano dalla tabella tenant_settings
|
||||||
|
(decifrate a runtime dal TenantSettingsService).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
conservatore_id: str = "production",
|
||||||
|
timeout_seconds: int = 120,
|
||||||
|
) -> None:
|
||||||
|
self.endpoint = endpoint.rstrip("/")
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.conservatore_id = conservatore_id
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
|
async def upload_versamento(
|
||||||
|
self,
|
||||||
|
sip_path: str,
|
||||||
|
sip_bytes: bytes,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> VersamentoResult:
|
||||||
|
"""
|
||||||
|
POST {endpoint}/versamento
|
||||||
|
Content-Type: application/octet-stream (o multipart/form-data per alcuni provider)
|
||||||
|
Authorization: Basic base64(user:pass)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError:
|
||||||
|
return VersamentoResult(
|
||||||
|
success=False,
|
||||||
|
message="httpx non installato nel worker. Aggiungere alla dipendenza.",
|
||||||
|
)
|
||||||
|
|
||||||
|
import base64
|
||||||
|
auth_str = base64.b64encode(
|
||||||
|
f"{self.username}:{self.password}".encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.endpoint}/versamento",
|
||||||
|
content=sip_bytes,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {auth_str}",
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"X-Tenant-ID": str(tenant_id),
|
||||||
|
"X-SIP-Path": sip_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201, 202):
|
||||||
|
data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
|
||||||
|
_fallback_id = str(uuid.uuid4())[:8]
|
||||||
|
versamento_id = data.get("versamento_id") or data.get("id") or f"VERS-{_fallback_id}"
|
||||||
|
return VersamentoResult(
|
||||||
|
success=True,
|
||||||
|
versamento_id=str(versamento_id),
|
||||||
|
message="Versamento accettato dal conservatore",
|
||||||
|
raw_response=data,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return VersamentoResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Conservatore ha risposto con errore HTTP {response.status_code}: {response.text[:500]}",
|
||||||
|
raw_response={"status_code": response.status_code, "body": response.text[:500]},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return VersamentoResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Errore di connessione al conservatore: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_versamento_status(
|
||||||
|
self, versamento_id: str
|
||||||
|
) -> VersamentoStatus:
|
||||||
|
"""GET {endpoint}/versamento/{versamento_id}"""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError:
|
||||||
|
return VersamentoStatus(
|
||||||
|
versamento_id=versamento_id,
|
||||||
|
status="unknown",
|
||||||
|
message="httpx non installato",
|
||||||
|
)
|
||||||
|
|
||||||
|
import base64
|
||||||
|
auth_str = base64.b64encode(
|
||||||
|
f"{self.username}:{self.password}".encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.endpoint}/versamento/{versamento_id}",
|
||||||
|
headers={"Authorization": f"Basic {auth_str}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
stato = data.get("stato", data.get("status", "unknown"))
|
||||||
|
return VersamentoStatus(
|
||||||
|
versamento_id=versamento_id,
|
||||||
|
status=str(stato),
|
||||||
|
message=data.get("message", ""),
|
||||||
|
rdv_available=data.get("rdv_disponibile", False),
|
||||||
|
raw_response=data,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return VersamentoStatus(
|
||||||
|
versamento_id=versamento_id,
|
||||||
|
status="error",
|
||||||
|
message=f"HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return VersamentoStatus(
|
||||||
|
versamento_id=versamento_id,
|
||||||
|
status="error",
|
||||||
|
message=f"Errore connessione: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_dip(self, versamento_id: str) -> DipResult:
|
||||||
|
"""POST {endpoint}/dip con il versamento_id"""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError:
|
||||||
|
return DipResult(success=False, message="httpx non installato")
|
||||||
|
|
||||||
|
import base64
|
||||||
|
auth_str = base64.b64encode(
|
||||||
|
f"{self.username}:{self.password}".encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.endpoint}/dip",
|
||||||
|
json={"versamento_id": versamento_id},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {auth_str}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201, 202):
|
||||||
|
data = response.json()
|
||||||
|
return DipResult(
|
||||||
|
success=True,
|
||||||
|
dip_id=str(data.get("dip_id", "")),
|
||||||
|
download_url=data.get("download_url"),
|
||||||
|
message="DIP disponibile",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return DipResult(
|
||||||
|
success=False,
|
||||||
|
message=f"HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return DipResult(success=False, message=f"Errore connessione: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Factory ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ConservatoreClient:
|
||||||
|
"""
|
||||||
|
Factory che istanzia il client corretto in base alla modalità.
|
||||||
|
|
||||||
|
Utilizzo dal worker:
|
||||||
|
creds = await tenant_settings_service.get_conservatore_credentials(tenant_id)
|
||||||
|
client = ConservatoreClient.from_tenant_credentials(creds)
|
||||||
|
result = await client.upload_versamento(sip_path, sip_bytes, tenant_id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_tenant_credentials(creds: dict) -> _BaseConservatoreClient:
|
||||||
|
"""
|
||||||
|
Crea il client appropriato dalla configurazione tenant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
creds: dizionario da TenantSettingsService.get_conservatore_credentials()
|
||||||
|
con chiavi: mode, conservatore_id, endpoint, username, password
|
||||||
|
"""
|
||||||
|
mode = creds.get("mode", "mock")
|
||||||
|
|
||||||
|
if mode == "production":
|
||||||
|
endpoint = creds.get("endpoint")
|
||||||
|
username = creds.get("username")
|
||||||
|
password = creds.get("password")
|
||||||
|
|
||||||
|
if not endpoint:
|
||||||
|
raise ValueError(
|
||||||
|
"Modalità produzione attiva ma conservatore_endpoint non configurato. "
|
||||||
|
"Verificare le impostazioni del tenant."
|
||||||
|
)
|
||||||
|
if not username or not password:
|
||||||
|
raise ValueError(
|
||||||
|
"Modalità produzione attiva ma credenziali conservatore mancanti. "
|
||||||
|
"Configurare username e password nelle impostazioni del tenant."
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProductionConservatoreClient(
|
||||||
|
endpoint=endpoint,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
conservatore_id=creds.get("conservatore_id", "production"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default: modalità mock (sicura per sviluppo)
|
||||||
|
return MockConservatoreClient()
|
||||||
Reference in New Issue
Block a user