Files
DevEnv nis2-agileandClaude Opus 4.8 b917d2da14 [FIX] Epic C / C5 hardening — esiti flotta di verifica multi-agente (mig.054)
Corretti i finding confermati dalla verifica multi-agente (5 major + minori):

SICUREZZA
- Stored XSS allegati: da blocklist a ALLOWLIST di estensioni innocue (no html/svg/js
  renderizzabili same-origin) in StakeholderPortalController::attachment e
  StakeholderActivityController::storeUpload; nome file randomizzato (random_bytes).
- Magic-link: scadenza (mig.054 stk_activity_targets.token_expires_at; send() imposta
  scadenza attività+30gg o +90gg; resolveTarget() → 410 TOKEN_EXPIRED se scaduto);
  rate-limit per-IP sugli endpoint del portale; comment/attachment bloccati su attività chiusa.

CORRETTEZZA
- send(): NON rigenera token né azzera lo stato dei destinatari già responded/acknowledged
  (prima ne perdeva l'esito); imposta token_expires_at.
- assign individuale: semantica "replace" (rimuove i deselezionati non ancora conclusi) +
  guard su lista vuota (evita 'IN ()').
- update(): conserva assign_mode esistente quando si modifica solo stak_code.

UI/UX/A11Y
- Editor opzioni per domande a scelta singola/multipla (prima degradavano a testo nel portale).
- Etichette stato/tipo localizzate; risposte mostrate inline (no alert()); escAttr nel portale
  (escape virgolette negli attributi); ARIA su modali/tab; voce sidebar anche in common.js.

OPEN ITEM (NON regressione C5, pre-esistente e ambientale): l'upload allegati restituisce
UPLOAD_ERROR in prod — move_uploaded_file/is_uploaded_file fallisce nella topologia
proxy→fastcgi (stesso pattern di evidence_files/AuditController, mai funzionato: la dir
uploads/evidence non esiste). rename/copy come www-data funzionano. Da investigare lato infra.
Il resto di C5 (questionari, firma-lettura, commenti, calendario, portale) è pienamente operativo.

Smoke prod OK: allowlist (.html→422, struttura ok), opzioni scelta nel portale, send no-reset,
replace individuale, token_expires_at presente. Additivo. v1.21.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:52:31 +02:00

181 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Portale stakeholder - NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<style>
body { background:#f1f5f9; margin:0; font-family:'Titillium Web',system-ui,sans-serif; color:#1e293b; }
.pw-wrap { max-width:720px; margin:0 auto; padding:24px 16px 60px; }
.pw-head { display:flex; align-items:center; gap:12px; margin-bottom:18px; }
.pw-head .logo { width:40px; height:40px; border-radius:9px; background:#0066CC; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:800; }
.pw-head h1 { font-size:1.1rem; margin:0; }
.pw-card { background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:22px 24px; box-shadow:0 6px 20px rgba(15,23,42,.05); margin-bottom:16px; }
.pw-card h2 { font-size:1.15rem; margin:0 0 6px; }
.pw-sub { color:#64748b; font-size:.88rem; margin-bottom:14px; }
.pw-content { white-space:pre-wrap; background:#f8fafc; border:1px solid #e2e8f0; border-radius:10px; padding:14px 16px; font-size:.92rem; line-height:1.6; max-height:340px; overflow:auto; }
.q-block { margin-bottom:18px; padding-bottom:16px; border-bottom:1px solid #f1f5f9; }
.q-block:last-child { border-bottom:none; }
.q-text { font-weight:600; font-size:.95rem; margin-bottom:8px; }
.q-req { color:#dc2626; }
.q-opt { display:flex; align-items:center; gap:8px; margin:5px 0; font-size:.92rem; }
.q-block input[type=text], .q-block textarea, .q-block input[type=number] { width:100%; padding:9px 10px; border:1px solid #cbd5e1; border-radius:8px; font-size:.92rem; }
.pw-field { margin-bottom:14px; }
.pw-field label { display:block; font-weight:600; font-size:.88rem; margin-bottom:5px; }
.pw-field input[type=text], .pw-field textarea { width:100%; padding:9px 10px; border:1px solid #cbd5e1; border-radius:8px; font-size:.92rem; }
.btn-primary { background:#0066CC; border-color:#0066CC; }
.pw-msg { padding:14px 16px; border-radius:10px; font-size:.92rem; margin-bottom:16px; }
.pw-ok { background:#dcfce7; color:#166534; border:1px solid #bbf7d0; }
.pw-err { background:#fee2e2; color:#991b1b; border:1px solid #fecaca; }
.pw-foot { text-align:center; color:#94a3b8; font-size:.78rem; margin-top:24px; }
.hidden { display:none !important; }
</style>
</head>
<body>
<div class="pw-wrap">
<div class="pw-head"><div class="logo">N2</div><h1>NIS2 Agile — Portale stakeholder</h1></div>
<div id="pw-app"><div class="pw-card"><div class="pw-sub">Caricamento…</div></div></div>
<div class="pw-foot">Strumento di supporto alla compliance NIS2. Le risposte sono trasmesse all'organizzazione che ti ha inviato il link.</div>
</div>
<script>
'use strict';
var TOKEN = new URLSearchParams(location.search).get('t') || '';
var DATA = null;
function el(id) { return document.getElementById(id); }
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
function app() { return el('pw-app'); }
async function apiGet(path) {
var r = await fetch('/api' + path, { headers: { 'Accept': 'application/json' } });
return r.json();
}
async function apiPost(path, body) {
var r = await fetch('/api' + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
return r.json();
}
function card(html) { return '<div class="pw-card">' + html + '</div>'; }
function msg(text, ok) { return '<div class="pw-msg ' + (ok ? 'pw-ok' : 'pw-err') + '">' + esc(text) + '</div>'; }
document.addEventListener('DOMContentLoaded', load);
async function load() {
if (!TOKEN) { app().innerHTML = card(msg('Link di accesso mancante.', false)); return; }
try {
var res = await apiGet('/stakeholder-portal/access?t=' + encodeURIComponent(TOKEN));
if (!res.success) { app().innerHTML = card(msg(res.message || 'Link non valido.', false)); return; }
DATA = res.data;
render();
} catch (e) { app().innerHTML = card(msg('Errore di connessione.', false)); }
}
function render() {
var a = DATA.activity, t = DATA.template;
var head = '<h2>' + esc(a.title) + '</h2><div class="pw-sub">Gentile <strong>' + esc(DATA.stakeholder_name) + '</strong>'
+ (a.description ? ' — ' + esc(a.description) : '') + '</div>';
if (DATA.submitted) {
app().innerHTML = card(head + msg(a.type === 'read_ack' ? 'Hai già confermato la lettura. Grazie.' : 'Hai già inviato le tue risposte. Grazie.', true))
+ commentCard();
return;
}
var bodyHtml;
if (a.type === 'read_ack') {
bodyHtml = '<div class="pw-content">' + esc(t && t.content ? t.content : 'Documento da leggere e confermare.') + '</div>'
+ '<div class="pw-field" style="margin-top:14px;"><label>Nome e cognome (firma)</label><input type="text" id="pw-name" maxlength="255" placeholder="Il tuo nome"></div>'
+ '<button class="btn btn-primary" onclick="doAck()">Confermo di aver letto</button>';
} else {
var qs = (t && t.questions) ? t.questions : [];
bodyHtml = qs.length ? qs.map(qHtml).join('') : '<div class="pw-sub">Nessuna domanda configurata.</div>';
bodyHtml += '<div class="pw-field" style="margin-top:6px;"><label>Nome e cognome (facoltativo)</label><input type="text" id="pw-name" maxlength="255"></div>'
+ '<button class="btn btn-primary" onclick="doRespond()">Invia risposte</button>';
}
app().innerHTML = '<div id="pw-flash"></div>' + card(head + bodyHtml) + commentCard();
}
function qHtml(q, i) {
var code = escAttr(q.code || ('Q' + (i + 1)));
var req = q.required ? ' <span class="q-req">*</span>' : '';
var inner = '';
if (q.type === 'yes_no') {
inner = ['Sì', 'No'].map(function (o) { return '<label class="q-opt"><input type="radio" name="' + code + '" value="' + o + '"> ' + o + '</label>'; }).join('');
} else if (q.type === 'scale_1_5') {
inner = [1,2,3,4,5].map(function (n) { return '<label class="q-opt" style="display:inline-flex;margin-right:14px;"><input type="radio" name="' + code + '" value="' + n + '"> ' + n + '</label>'; }).join('');
} else if (q.type === 'single_choice' && q.options) {
inner = q.options.map(function (o) { return '<label class="q-opt"><input type="radio" name="' + code + '" value="' + escAttr(o) + '"> ' + esc(o) + '</label>'; }).join('');
} else if (q.type === 'multi_choice' && q.options) {
inner = q.options.map(function (o) { return '<label class="q-opt"><input type="checkbox" name="' + code + '" value="' + escAttr(o) + '"> ' + esc(o) + '</label>'; }).join('');
} else if (q.type === 'number') {
inner = '<input type="number" name="' + code + '">';
} else {
inner = '<textarea name="' + code + '" rows="2"></textarea>';
}
return '<div class="q-block" data-code="' + code + '" data-type="' + escAttr(q.type || 'text') + '"><div class="q-text">' + esc(q.text) + req + '</div>' + inner + '</div>';
}
function collectAnswers() {
var out = {};
document.querySelectorAll('.q-block').forEach(function (b) {
var code = b.getAttribute('data-code'), type = b.getAttribute('data-type');
if (type === 'multi_choice') {
out[code] = Array.prototype.filter.call(b.querySelectorAll('input[type=checkbox]'), function (c) { return c.checked; }).map(function (c) { return c.value; });
} else if (type === 'yes_no' || type === 'single_choice' || type === 'scale_1_5') {
var sel = b.querySelector('input:checked'); out[code] = sel ? sel.value : '';
} else {
var f = b.querySelector('textarea,input'); out[code] = f ? f.value.trim() : '';
}
});
return out;
}
function flash(text, ok) { var f = el('pw-flash'); if (f) f.innerHTML = msg(text, ok); }
async function doRespond() {
var answers = collectAnswers();
// pre-check campi obbligatori lato client (evidenzia i blocchi mancanti)
var qs = (DATA.template && DATA.template.questions) || [];
var missing = [];
document.querySelectorAll('.q-block').forEach(function (b) { b.style.borderLeft = ''; });
qs.forEach(function (q) {
if (!q.required) return;
var v = answers[q.code];
if (v == null || v === '' || (Array.isArray(v) && !v.length)) {
missing.push(q.text);
var b = document.querySelector('.q-block[data-code="' + (q.code || '') + '"]');
if (b) b.style.borderLeft = '3px solid #dc2626';
}
});
if (missing.length) { flash('Rispondi alle domande obbligatorie: ' + missing.join('; '), false); window.scrollTo(0, 0); return; }
var res = await apiPost('/stakeholder-portal/respond', { t: TOKEN, answers: answers, respondent_name: (el('pw-name') || {}).value });
if (!res.success) { flash(res.message || 'Errore', false); return; }
DATA.submitted = true; render(); window.scrollTo(0, 0);
}
async function doAck() {
var res = await apiPost('/stakeholder-portal/acknowledge', { t: TOKEN, respondent_name: (el('pw-name') || {}).value });
if (!res.success) { flash(res.message || 'Errore', false); return; }
DATA.submitted = true; render(); window.scrollTo(0, 0);
}
function commentCard() {
return card('<h2 style="font-size:1rem;">Un commento? (facoltativo)</h2>'
+ '<div class="pw-field"><textarea id="pw-comment" rows="2" placeholder="Scrivi una nota per l\'organizzazione"></textarea></div>'
+ '<div id="pw-comment-flash"></div>'
+ '<button class="btn btn-outline-primary btn-sm" onclick="doComment()">Invia commento</button>');
}
async function doComment() {
var body = (el('pw-comment') || {}).value || '';
if (!body.trim()) return;
var res = await apiPost('/stakeholder-portal/comment', { t: TOKEN, body: body });
var f = el('pw-comment-flash');
if (f) f.innerHTML = msg(res.success ? 'Commento inviato. Grazie.' : (res.message || 'Errore'), res.success);
if (res.success && el('pw-comment')) el('pw-comment').value = '';
}
</script>
</body>
</html>