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>
181 lines
11 KiB
HTML
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, '"'); }
|
|
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>
|