Sprint completo — prodotto presentation-ready:
Services API (read-only, API Key + scope):
- GET /api/services/status|compliance-summary|risks-feed|incidents-feed
- GET /api/services/controls-status|assets-critical|suppliers-risk|policies-approved
- GET /api/services/openapi (spec OpenAPI 3.0.3 JSON)
Webhook Outbound (Stripe-like HMAC-SHA256):
- CRUD api_keys + webhook_subscriptions (Settings → 2 nuovi tab)
- WebhookService: retry 3x backoff (0s/5min/30min), delivery log
- Trigger auto in IncidentController, RiskController, PolicyController
- Delivery log, test ping, processRetry
Nuovi moduli:
- WhistleblowingController (Art.32 NIS2): anonimato garantito, timeline, token tracking
- NormativeController: feed NIS2/ACN/DORA con ACK tracciato per audit
Frontend:
- whistleblowing.html: form submit anonimo/firmato + gestione CISO
- normative.html: feed con presa visione documentata + progress bar ACK
- public/docs/api.html: documentazione API dark theme (Swagger-like)
- settings.html: tab API Keys + tab Webhook
- integrations/: guide per lg231, SustainAI, AllRisk, SIEM (widget + codice)
- Sidebar: Segnalazioni + Normative aggiunte a common.js
DB: migration 007 (api_keys, webhook_subscriptions, webhook_deliveries),
008 (whistleblowing_reports + timeline),
009 (normative_updates + normative_ack + seed NIS2/ACN/DORA/ISO)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
257 lines
16 KiB
HTML
257 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="it">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Aggiornamenti Normativi - NIS2 Agile</title>
|
|
<link rel="stylesheet" href="/css/style.css">
|
|
<style>
|
|
.impact-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
|
|
.impact-critical { background: var(--danger-bg); color: var(--danger); }
|
|
.impact-high { background: #fff7ed; color: #c2410c; }
|
|
.impact-medium { background: var(--warning-bg); color: #a16207; }
|
|
.impact-low { background: var(--gray-100); color: var(--gray-600); }
|
|
.impact-informational { background: rgba(6,182,212,0.1); color: #0891b2; }
|
|
.ack-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; }
|
|
.ack-done { background: var(--success-bg); color: var(--success); }
|
|
.ack-pending { background: var(--warning-bg); color: #a16207; }
|
|
.source-chip { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; background: var(--gray-100); color: var(--gray-600); font-family: monospace; }
|
|
.update-card { border: 1px solid var(--gray-200); border-radius: var(--border-radius-lg); padding: 20px; margin-bottom: 16px; transition: all var(--transition-fast); }
|
|
.update-card:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(6,182,212,0.1); }
|
|
.update-card.action-required { border-left: 3px solid var(--warning); }
|
|
.update-card.acked { opacity: 0.7; }
|
|
.ack-progress { height: 6px; background: var(--gray-200); border-radius: 3px; overflow: hidden; }
|
|
.ack-progress-bar { height: 100%; background: var(--primary); border-radius: 3px; transition: width 0.5s ease; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-layout">
|
|
<div id="sidebar-container"></div>
|
|
<main class="main-content">
|
|
<div class="page-header">
|
|
<div class="page-header-content">
|
|
<h1 class="page-title">Aggiornamenti Normativi</h1>
|
|
<p class="page-subtitle">Feed NIS2 / ACN / DORA — Presa visione obbligatoria per audit compliance</p>
|
|
</div>
|
|
<div class="page-header-actions">
|
|
<button class="btn btn-secondary" onclick="loadPending()">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
|
Solo Pendenti
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="stats-grid mb-24" id="stats-grid">
|
|
<div class="stat-card"><div class="spinner"></div></div>
|
|
</div>
|
|
|
|
<!-- Ack Progress bar -->
|
|
<div class="card mb-24">
|
|
<div class="card-body" style="padding:16px 20px;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
|
|
<span style="font-size:0.875rem; font-weight:600; color:var(--gray-700);">Avanzamento presa visione</span>
|
|
<span id="ack-pct" style="font-size:0.875rem; font-weight:700; color:var(--primary);">—</span>
|
|
</div>
|
|
<div class="ack-progress">
|
|
<div class="ack-progress-bar" id="ack-bar" style="width:0%"></div>
|
|
</div>
|
|
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:6px;">Documentare la presa visione degli aggiornamenti normativi è richiesto per la compliance NIS2 Art.21.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="card mb-16">
|
|
<div class="card-body" style="padding:14px 20px;">
|
|
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
|
|
<select id="filter-source" class="form-control" style="width:auto;" onchange="loadUpdates()">
|
|
<option value="">Tutte le fonti</option>
|
|
<option value="dlgs_138_2024">D.Lgs. 138/2024</option>
|
|
<option value="nis2_directive">Direttiva NIS2</option>
|
|
<option value="acn_guideline">ACN Guideline</option>
|
|
<option value="dora">DORA</option>
|
|
<option value="enisa">ENISA</option>
|
|
<option value="iso27001">ISO 27001</option>
|
|
</select>
|
|
<select id="filter-impact" class="form-control" style="width:auto;" onchange="loadUpdates()">
|
|
<option value="">Tutti i livelli</option>
|
|
<option value="critical">Critico</option>
|
|
<option value="high">Alto</option>
|
|
<option value="medium">Medio</option>
|
|
<option value="low">Basso</option>
|
|
<option value="informational">Informativo</option>
|
|
</select>
|
|
<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
|
|
<input type="checkbox" id="filter-action" onchange="loadUpdates()" style="accent-color:var(--primary);">
|
|
Solo azione richiesta
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="updates-container"><div class="spinner" style="margin:60px auto;"></div></div>
|
|
</main>
|
|
</div>
|
|
|
|
<script src="/js/api.js"></script>
|
|
<script src="/js/common.js"></script>
|
|
<script src="/js/i18n.js"></script>
|
|
<script src="/js/help.js"></script>
|
|
<script>
|
|
if (!checkAuth()) throw new Error('Not authenticated');
|
|
loadSidebar();
|
|
I18n.init();
|
|
|
|
const sourceLabels = {
|
|
nis2_directive: 'NIS2 Directive', dlgs_138_2024: 'D.Lgs. 138/2024',
|
|
acn_guideline: 'ACN', dora: 'DORA', enisa: 'ENISA', iso27001: 'ISO 27001', other: 'Altro'
|
|
};
|
|
const impactLabels = {
|
|
critical: 'Critico', high: 'Alto', medium: 'Medio', low: 'Basso', informational: 'Informativo'
|
|
};
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const result = await api.request('GET', '/normative/stats');
|
|
if (result.success) renderStats(result.data);
|
|
} catch (e) {}
|
|
}
|
|
|
|
function renderStats(d) {
|
|
document.getElementById('stats-grid').innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-label">Aggiornamenti Totali</div>
|
|
<div class="stat-value">${d.total_updates || 0}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Prese Visione</div>
|
|
<div class="stat-value" style="color:var(--success);">${d.acknowledged || 0}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Pendenti</div>
|
|
<div class="stat-value" style="color:var(--warning);">${d.pending || 0}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Compliance ACK</div>
|
|
<div class="stat-value" style="color:var(--primary);">${d.ack_rate || 0}%</div>
|
|
</div>`;
|
|
|
|
document.getElementById('ack-pct').textContent = (d.ack_rate || 0) + '%';
|
|
document.getElementById('ack-bar').style.width = (d.ack_rate || 0) + '%';
|
|
}
|
|
|
|
async function loadUpdates() {
|
|
const container = document.getElementById('updates-container');
|
|
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
|
|
try {
|
|
const params = new URLSearchParams();
|
|
const source = document.getElementById('filter-source').value;
|
|
const impact = document.getElementById('filter-impact').value;
|
|
const action = document.getElementById('filter-action').checked;
|
|
if (source) params.append('source', source);
|
|
if (impact) params.append('impact', impact);
|
|
if (action) params.append('action_required', '1');
|
|
|
|
const result = await api.request('GET', '/normative/list?' + params);
|
|
if (result.success) renderUpdates(result.data.updates || []);
|
|
} catch (e) {
|
|
container.innerHTML = '<div class="empty-state"><h4>Errore caricamento aggiornamenti</h4></div>';
|
|
}
|
|
}
|
|
|
|
async function loadPending() {
|
|
const container = document.getElementById('updates-container');
|
|
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
|
|
try {
|
|
const result = await api.request('GET', '/normative/pending');
|
|
if (result.success) renderUpdates(result.data.pending || [], true);
|
|
} catch (e) { container.innerHTML = '<div class="empty-state"><h4>Errore.</h4></div>'; }
|
|
}
|
|
|
|
function renderUpdates(updates, pendingOnly) {
|
|
const container = document.getElementById('updates-container');
|
|
if (!updates.length) {
|
|
container.innerHTML = `<div class="empty-state">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="40" height="40" style="color:var(--gray-300)"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm2 10a1 1 0 10-2 0v3a1 1 0 102 0v-3zm2-3a1 1 0 011 1v5a1 1 0 11-2 0v-5a1 1 0 011-1zm4-1a1 1 0 10-2 0v6a1 1 0 102 0V8z" clip-rule="evenodd"/></svg>
|
|
<h4>${pendingOnly ? 'Nessun aggiornamento pendente — tutto preso in visione!' : 'Nessun aggiornamento trovato'}</h4>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
updates.forEach(u => {
|
|
const acked = u.is_acknowledged || !!(u.acknowledged_at);
|
|
const actionClass = u.action_required ? 'action-required' : '';
|
|
const ackedClass = acked ? 'acked' : '';
|
|
const domains = (u.affected_domains || []).map(d =>
|
|
`<span class="source-chip">${escapeHtml(d)}</span>`
|
|
).join(' ');
|
|
|
|
html += `<div class="update-card ${actionClass} ${ackedClass}" id="update-${u.id}">
|
|
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
|
|
<div style="flex:1;">
|
|
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:8px;">
|
|
<span class="source-chip">${escapeHtml(sourceLabels[u.source] || u.source)}</span>
|
|
<span class="impact-badge impact-${u.impact_level}">${escapeHtml(impactLabels[u.impact_level] || u.impact_level)}</span>
|
|
${u.action_required ? '<span style="font-size:0.7rem; font-weight:700; color:var(--warning); background:var(--warning-bg); padding:2px 8px; border-radius:4px;">Azione richiesta</span>' : ''}
|
|
${acked
|
|
? `<span class="ack-badge ack-done"><svg viewBox="0 0 20 20" fill="currentColor" width="11" height="11"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>Preso in visione ${u.ack_by_name ? 'da ' + escapeHtml(u.ack_by_name) : ''}</span>`
|
|
: '<span class="ack-badge ack-pending">Da prendere in visione</span>'
|
|
}
|
|
</div>
|
|
<h3 style="font-size:1rem; font-weight:700; color:var(--gray-900); margin-bottom:6px;">${escapeHtml(u.title)}</h3>
|
|
${u.reference ? `<p style="font-size:0.75rem; color:var(--gray-500); margin-bottom:8px;"><code>${escapeHtml(u.reference)}</code></p>` : ''}
|
|
<p style="font-size:0.8125rem; color:var(--gray-600); line-height:1.6; margin-bottom:10px;">${escapeHtml(u.summary)}</p>
|
|
${domains ? `<div style="display:flex; gap:6px; flex-wrap:wrap;">${domains}</div>` : ''}
|
|
</div>
|
|
<div style="display:flex; flex-direction:column; gap:8px; align-items:flex-end; flex-shrink:0;">
|
|
${u.effective_date ? `<p style="font-size:0.75rem; color:var(--gray-500);">In vigore: <strong>${formatDate(u.effective_date)}</strong></p>` : ''}
|
|
${u.url ? `<a href="${escapeHtml(u.url)}" target="_blank" rel="noopener" class="btn btn-sm btn-secondary" style="font-size:0.75rem;">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/></svg>
|
|
Fonte
|
|
</a>` : ''}
|
|
${!acked ? `<button class="btn btn-sm btn-primary" onclick="acknowledgeUpdate(${u.id})">
|
|
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
|
Prendi in visione
|
|
</button>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
async function acknowledgeUpdate(id) {
|
|
showModal('Presa Visione', `
|
|
<p style="font-size:0.875rem; color:var(--gray-600); margin-bottom:16px;">
|
|
Documentare la presa visione dell'aggiornamento normativo. Opzionalmente aggiungere note sull'impatto per la tua organizzazione.
|
|
</p>
|
|
<div class="form-group">
|
|
<label class="form-label">Note (opzionale)</label>
|
|
<textarea id="ack-notes" class="form-control" rows="3" placeholder="Es. Analizzato con il team di compliance. Nessun impatto diretto rilevato..."></textarea>
|
|
</div>
|
|
`, `<button class="btn btn-primary" onclick="confirmAck(${id})">Conferma presa visione</button>
|
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>`);
|
|
}
|
|
|
|
async function confirmAck(id) {
|
|
const notes = document.getElementById('ack-notes').value;
|
|
try {
|
|
const result = await api.request('POST', `/normative/${id}/ack`, { notes });
|
|
if (result.success) {
|
|
closeModal();
|
|
showNotification('Presa visione registrata e documentata.', 'success');
|
|
loadUpdates();
|
|
loadStats();
|
|
} else showNotification(result.message || 'Errore.', 'error');
|
|
} catch (e) { showNotification('Errore di connessione.', 'error'); }
|
|
}
|
|
|
|
// Init
|
|
loadStats();
|
|
loadUpdates();
|
|
</script>
|
|
</body>
|
|
</html>
|