[FEAT] Self-assessment fornitori (P3 supply chain) - portale pubblico con token
- Migrazione 027: tabella supplier_questionnaires (token hash, risposte, score, risk_level, scadenza) - SupplyChainController: sendQuestionnaire (JWT, genera link 30gg), publicQuestionnaire + submitPublicQuestionnaire (NO auth, token), questionnaireStatus - 8 domande sicurezza Art.21.2.d (ISO27001/MFA/patching/backup/incident/access/encryption/subfornitori) pesate -> score 0-100 -> risk_level + aggiornamento suppliers.risk_score - public/supplier-assessment.html: portale standalone (no login) per il fornitore - Pulito route map supplychain (rimossi duplicati + entry malformata PueT + metodi inesistenti) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
56ce97d1e1
commit
1a5db30073
35
docs/sql/027_supplier_self_assessment.sql
Normal file
35
docs/sql/027_supplier_self_assessment.sql
Normal file
@ -0,0 +1,35 @@
|
||||
-- ============================================================================
|
||||
-- Migration 027 - Supplier self-assessment portal (P3 supply chain)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Abilita l'invio di questionari di sicurezza ai fornitori (Art.21.2.d NIS2)
|
||||
-- e la compilazione tramite portale pubblico con token (senza login).
|
||||
--
|
||||
-- supplier_questionnaires: traccia invio, token (hash), risposte, score.
|
||||
--
|
||||
-- Idempotente. Rilanciabile.
|
||||
-- mysql -h localhost nis2_agile_db -e "source docs/sql/027_supplier_self_assessment.sql"
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supplier_questionnaires (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
organization_id INT NOT NULL,
|
||||
supplier_id INT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL COMMENT 'SHA-256 del token inviato al fornitore',
|
||||
status ENUM('sent','completed','expired') NOT NULL DEFAULT 'sent',
|
||||
answers JSON NULL COMMENT 'Risposte del fornitore {question_key: value}',
|
||||
score INT NULL COMMENT 'Punteggio 0-100 calcolato dalle risposte',
|
||||
risk_level ENUM('low','medium','high','critical') NULL,
|
||||
sent_to_email VARCHAR(255) NULL,
|
||||
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_sq_token (token_hash),
|
||||
KEY idx_sq_org (organization_id),
|
||||
KEY idx_sq_supplier (supplier_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Questionari di self-assessment sicurezza inviati ai fornitori (Art.21.2.d)';
|
||||
|
||||
-- ROLLBACK:
|
||||
-- DROP TABLE IF EXISTS supplier_questionnaires;
|
||||
118
public/supplier-assessment.html
Normal file
118
public/supplier-assessment.html
Normal file
@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Questionario di Sicurezza Fornitore — NIS2 Agile</title>
|
||||
<style>
|
||||
:root { --primary:#06B6D4; --ok:#22c55e; --warn:#eab308; --bad:#ef4444; --ink:#0f172a; --muted:#64748b; --line:#e2e8f0; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:#f1f5f9; color:var(--ink); line-height:1.5; }
|
||||
.wrap { max-width:720px; margin:0 auto; padding:24px 16px 60px; }
|
||||
.brand { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
|
||||
.brand .dot { width:14px; height:14px; border-radius:4px; background:var(--primary); }
|
||||
.brand strong { font-size:1.05rem; }
|
||||
.card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:22px; margin-top:16px; box-shadow:0 1px 3px rgba(0,0,0,.05); }
|
||||
h1 { font-size:1.4rem; margin:.2rem 0; }
|
||||
.sub { color:var(--muted); font-size:.9rem; }
|
||||
.q { padding:14px 0; border-bottom:1px solid var(--line); }
|
||||
.q:last-child { border-bottom:none; }
|
||||
.q p { margin:0 0 8px; font-weight:500; }
|
||||
.opts { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.opt { flex:1; min-width:90px; text-align:center; padding:9px 6px; border:1.5px solid var(--line); border-radius:8px; cursor:pointer; font-size:.85rem; transition:.15s; user-select:none; }
|
||||
.opt:hover { border-color:var(--primary); }
|
||||
.opt.sel { background:var(--primary); color:#fff; border-color:var(--primary); }
|
||||
.btn { width:100%; margin-top:18px; padding:13px; border:none; border-radius:8px; background:var(--primary); color:#fff; font-size:1rem; font-weight:600; cursor:pointer; }
|
||||
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.msg { padding:14px; border-radius:8px; margin-top:12px; font-size:.9rem; }
|
||||
.msg.err { background:#fef2f2; color:var(--bad); }
|
||||
.msg.ok { background:#f0fdf4; color:var(--ok); }
|
||||
.result { text-align:center; padding:20px; }
|
||||
.result .score { font-size:3rem; font-weight:700; color:var(--primary); }
|
||||
.spinner { width:34px; height:34px; border:3px solid var(--line); border-top-color:var(--primary); border-radius:50%; animation:spin .8s linear infinite; margin:40px auto; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
.foot { text-align:center; color:var(--muted); font-size:.75rem; margin-top:24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="brand"><span class="dot"></span><strong>NIS2 Agile</strong></div>
|
||||
<div id="root"><div class="spinner"></div></div>
|
||||
<div class="foot">Questionario di sicurezza ai sensi dell'Art. 21.2 (d) Direttiva (UE) 2022/2555 (NIS2). I dati sono trattati dal committente per la valutazione del rischio della catena di approvvigionamento.</div>
|
||||
</div>
|
||||
<script>
|
||||
const API = '/api';
|
||||
const token = new URLSearchParams(location.search).get('token') || '';
|
||||
const root = document.getElementById('root');
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
let QUESTIONS = [], SCALE = {}, answers = {};
|
||||
|
||||
async function load() {
|
||||
if (!token) { return showErr('Link non valido: token mancante.'); }
|
||||
try {
|
||||
const r = await fetch(`${API}/supplychain/public-questionnaire?token=${encodeURIComponent(token)}`);
|
||||
const j = await r.json();
|
||||
if (!j.success) return showErr(j.message || 'Questionario non disponibile.');
|
||||
QUESTIONS = j.data.questions; SCALE = j.data.answer_scale;
|
||||
render(j.data.supplier_name);
|
||||
} catch (e) { showErr('Errore di connessione.'); }
|
||||
}
|
||||
|
||||
function render(supplierName) {
|
||||
const qs = QUESTIONS.map((q, i) => `
|
||||
<div class="q" data-key="${esc(q.key)}">
|
||||
<p>${i + 1}. ${esc(q.question)}</p>
|
||||
<div class="opts">
|
||||
${Object.entries(SCALE).map(([v, lbl]) => `<div class="opt" data-v="${esc(v)}" onclick="pick('${esc(q.key)}','${esc(v)}',this)">${esc(lbl)}</div>`).join('')}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
root.innerHTML = `
|
||||
<div class="card">
|
||||
<h1>Questionario di Sicurezza</h1>
|
||||
<p class="sub">Compilato da: <strong>${esc(supplierName)}</strong></p>
|
||||
</div>
|
||||
<div class="card">${qs}
|
||||
<div id="formMsg"></div>
|
||||
<button class="btn" id="submitBtn" onclick="submit()">Invia questionario</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function pick(key, val, el) {
|
||||
answers[key] = val;
|
||||
el.parentNode.querySelectorAll('.opt').forEach(o => o.classList.remove('sel'));
|
||||
el.classList.add('sel');
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (Object.keys(answers).length < QUESTIONS.length) {
|
||||
document.getElementById('formMsg').innerHTML = '<div class="msg err">Rispondi a tutte le domande prima di inviare.</div>';
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true; btn.textContent = 'Invio in corso...';
|
||||
try {
|
||||
const r = await fetch(`${API}/supplychain/submit-public-questionnaire`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, answers })
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.success) { btn.disabled = false; btn.textContent = 'Invia questionario';
|
||||
document.getElementById('formMsg').innerHTML = `<div class="msg err">${esc(j.message)}</div>`; return; }
|
||||
const lvl = { low:['Basso','#22c55e'], medium:['Medio','#eab308'], high:['Alto','#f97316'], critical:['Critico','#ef4444'] }[j.data.risk_level] || ['',''];
|
||||
root.innerHTML = `<div class="card result">
|
||||
<div class="score">${j.data.score}/100</div>
|
||||
<p>Grazie. Il questionario e stato inviato con successo.</p>
|
||||
<p class="sub">Livello di rischio valutato: <strong style="color:${lvl[1]}">${lvl[0]}</strong></p>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
btn.disabled = false; btn.textContent = 'Invia questionario';
|
||||
document.getElementById('formMsg').innerHTML = '<div class="msg err">Errore di connessione.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function showErr(m) { root.innerHTML = `<div class="card"><div class="msg err">${esc(m)}</div></div>`; }
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user