Versamento su API AgID

This commit is contained in:
2026-03-19 14:43:36 +01:00
parent 06dfbfcbc4
commit 4e19090f0f
12 changed files with 1319 additions and 3 deletions
+60
View File
@@ -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
},
}
+456 -3
View File
@@ -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 (
<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 ──────────────────────────────────────────────────────────────────
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<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 ── */
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<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
return (
@@ -193,6 +333,319 @@ export function SettingsPage() {
</Button>
</div>
</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>
)
}