From 4e19090f0f8ca4f0170aefae37080efe122f0336 Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Thu, 19 Mar 2026 14:43:36 +0100 Subject: [PATCH] Versamento su API AgID --- .../alembic/versions/0005_tenant_settings.py | 61 +++ backend/app/api/v1/settings.py | 59 +++ backend/app/core/exceptions.py | 8 + backend/app/main.py | 2 + backend/app/models/__init__.py | 1 + backend/app/models/tenant_settings.py | 70 +++ backend/app/schemas/tenant_settings.py | 70 +++ .../app/services/tenant_settings_service.py | 152 ++++++ frontend/src/api/settings.api.ts | 60 +++ frontend/src/pages/Settings/SettingsPage.tsx | 459 +++++++++++++++++- worker/app/archival/__init__.py | 1 + worker/app/archival/conservatore_client.py | 379 +++++++++++++++ 12 files changed, 1319 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/0005_tenant_settings.py create mode 100644 backend/app/api/v1/settings.py create mode 100644 backend/app/models/tenant_settings.py create mode 100644 backend/app/schemas/tenant_settings.py create mode 100644 backend/app/services/tenant_settings_service.py create mode 100644 frontend/src/api/settings.api.ts create mode 100644 worker/app/archival/__init__.py create mode 100644 worker/app/archival/conservatore_client.py diff --git a/backend/alembic/versions/0005_tenant_settings.py b/backend/alembic/versions/0005_tenant_settings.py new file mode 100644 index 0000000..e678595 --- /dev/null +++ b/backend/alembic/versions/0005_tenant_settings.py @@ -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") diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py new file mode 100644 index 0000000..3bf9691 --- /dev/null +++ b/backend/app/api/v1/settings.py @@ -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) diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py index 4a28ea9..bf71dcf 100644 --- a/backend/app/core/exceptions.py +++ b/backend/app/core/exceptions.py @@ -121,3 +121,11 @@ class ValidationError(HTTPException): status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index d887c26..c212ed8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from slowapi.middleware import SlowAPIMiddleware 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 settings as settings_router from app.config import get_settings from app.core.logging import get_logger, setup_logging 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(notifications.router, prefix=API_PREFIX) app.include_router(labels.router, prefix=API_PREFIX) +app.include_router(settings_router.router, prefix=API_PREFIX) # ─── Health check ───────────────────────────────────────────────────────────── diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5e2c236..eb7d327 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,3 +9,4 @@ from app.models.label import Label, MessageLabel # noqa: F401 from app.models.permission import MailboxPermission # 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.tenant_settings import TenantSettings # noqa: F401 diff --git a/backend/app/models/tenant_settings.py b/backend/app/models/tenant_settings.py new file mode 100644 index 0000000..acf15bf --- /dev/null +++ b/backend/app/models/tenant_settings.py @@ -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"" diff --git a/backend/app/schemas/tenant_settings.py b/backend/app/schemas/tenant_settings.py new file mode 100644 index 0000000..d200349 --- /dev/null +++ b/backend/app/schemas/tenant_settings.py @@ -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 diff --git a/backend/app/services/tenant_settings_service.py b/backend/app/services/tenant_settings_service.py new file mode 100644 index 0000000..f7c881c --- /dev/null +++ b/backend/app/services/tenant_settings_service.py @@ -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 + ), + } diff --git a/frontend/src/api/settings.api.ts b/frontend/src/api/settings.api.ts new file mode 100644 index 0000000..f18f64b --- /dev/null +++ b/frontend/src/api/settings.api.ts @@ -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 => { + const { data } = await apiClient.get('/settings') + return data + }, + + /** + * Aggiorna le impostazioni del tenant. + * Solo i campi forniti vengono modificati (semantica PATCH). + */ + update: async (payload: TenantSettingsUpdate): Promise => { + const { data } = await apiClient.put('/settings', payload) + return data + }, +} diff --git a/frontend/src/pages/Settings/SettingsPage.tsx b/frontend/src/pages/Settings/SettingsPage.tsx index 4493293..b4150ca 100644 --- a/frontend/src/pages/Settings/SettingsPage.tsx +++ b/frontend/src/pages/Settings/SettingsPage.tsx @@ -1,17 +1,33 @@ /** - * SettingsPage – impostazioni profilo dell'utente corrente. + * SettingsPage – impostazioni profilo utente e configurazione tenant (admin). * * Sezioni: * - Informazioni profilo (nome visualizzato, email, ruolo) * - Modifica nome * - Cambio password + * - [Solo admin] Archiviazione Sostitutiva – toggle mock/produzione + credenziali conservatore */ -import { useState } from 'react' -import { Settings, User, Lock, Save } from 'lucide-react' +import { useEffect, useState } from 'react' +import { + Settings, + User, + Lock, + Save, + Archive, + ChevronDown, + ChevronUp, + CheckCircle, + AlertTriangle, + Eye, + EyeOff, + FlaskConical, + Zap, +} from 'lucide-react' import { useAuth } from '@/hooks/useAuth' import { useAuthStore } from '@/store/auth.store' import { usersApi } from '@/api/users.api' +import { settingsApi, type TenantSettingsResponse, type ArchivalMode } from '@/api/settings.api' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' 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 ( + + + Produzione + + ) + } + return ( + + + Mock (simulato) + + ) +} + // ─── Pagina ────────────────────────────────────────────────────────────────── export function SettingsPage() { const { user } = useAuth() const loadUser = useAuthStore((s) => s.loadUser) + const isAdmin = user?.role === 'admin' || user?.role === 'super_admin' /* ── Stato modifica nome ── */ const [fullName, setFullName] = useState(user?.full_name ?? '') @@ -48,6 +84,49 @@ export function SettingsPage() { const [confirmPassword, setConfirmPassword] = useState('') const [savingPwd, setSavingPwd] = useState(false) + /* ── Stato impostazioni archiviazione (admin) ── */ + const [archivalSettings, setArchivalSettings] = useState(null) + const [loadingArchival, setLoadingArchival] = useState(false) + const [archivalExpanded, setArchivalExpanded] = useState(false) + + // Form archiviazione + const [archivalMode, setArchivalMode] = useState('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 ── */ const handleSaveName = async () => { 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[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 return ( @@ -193,6 +333,319 @@ export function SettingsPage() { + + {/* ── Card: Archiviazione Sostitutiva (solo admin) ── */} + {isAdmin && ( + + {/* Header collassabile */} + + + {archivalExpanded && ( +
+ + {loadingArchival ? ( +

+ Caricamento impostazioni… +

+ ) : ( + <> + {/* ── Toggle modalità ── */} +
+ +

+ In modalità mock le operazioni di versamento vengono + simulate localmente senza inviare dati a sistemi esterni. Attiva la + modalità produzione solo dopo aver configurato l'endpoint + e le credenziali del conservatore AgID. +

+ +
+ {/* Bottone Mock */} + + + {/* Bottone Produzione */} + +
+ + {/* Banner conferma passaggio a produzione */} + {showProductionConfirm && archivalMode === 'mock' && ( +
+ +
+

+ Stai per attivare la modalità produzione +

+

+ I versamenti verranno inviati al conservatore AgID reale. + Assicurati che l'endpoint e le credenziali siano corretti. +

+
+ + · + +
+
+
+ )} +
+ +
+ + {/* ── Identificativo conservatore ── */} +
+ + setConservatoreId(e.target.value)} + placeholder="es. aruba-cons, docuvision, namirial" + /> +

+ Codice identificativo del provider di conservazione (usato nei log dei versamenti). +

+
+ + {/* ── Endpoint (mostrato sempre, obbligatorio in produzione) ── */} +
+ + setConservatoreEndpoint(e.target.value)} + placeholder="https://conservatore.provider.it/api/v1" + className={ + archivalMode === 'production' && !conservatoreEndpoint + ? 'border-red-300 focus:ring-red-400' + : '' + } + /> + {archivalMode === 'mock' && ( +

+ Non utilizzato in modalità mock – puoi configurarlo in anticipo. +

+ )} + {archivalMode === 'production' && !conservatoreEndpoint && ( +

+ Obbligatorio per la modalità produzione +

+ )} +
+ + {/* ── Credenziali (mostrate sempre, più evidenti in produzione) ── */} +
+
+ +

+ Vengono salvate cifrate (AES-256-GCM). + {archivalSettings?.conservatore_username_configured && ( + + ✓ Username configurata + + )} + {archivalSettings?.conservatore_password_configured && ( + + ✓ Password configurata + + )} +

+
+ +
+ + setConservatoreUsername(e.target.value)} + placeholder={ + archivalSettings?.conservatore_username_configured + ? '(credenziale già salvata – lascia vuoto per mantenerla)' + : 'Inserisci username conservatore' + } + autoComplete="off" + /> +
+ +
+ +
+ 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" + /> + +
+
+
+ + {/* ── Note operative ── */} +
+ +