[FEAT] Epic C / C5.2b — Portale esterno stakeholder + sotto-dashboard feedback (mig.053)
Completa C5 (Epic C). Gli stakeholder rispondono in self-service tramite magic-link
(token SHA-256 per destinatario, NESSUN account/JWT); il compliance manager vede gli
esiti, i commenti e gli allegati nel dettaglio dell'attività.
- StakeholderPortalController (non-JWT, token-only): access / respond (questionario) /
acknowledge (firma di avvenuta lettura) / comment / attachment. Submit one-shot (409),
validazione risposte obbligatorie, anti-IDOR (un token = un solo destinatario),
auto-completamento attività quando tutti hanno risposto/firmato.
- StakeholderActivityController: feedback (risposte per destinatario), comments
(GET/POST), attachments (upload interno + lista; riuso evidence_files entity_type=
'stk_activity', file sotto public/uploads/stk_activity/{org}/).
- mig.053: stk_activity_responses (answers JSON / acknowledged_at), stk_activity_comments
(interni/esterni). Estende il seeder idempotente.
- Frontend: stk-portal.html (pagina pubblica dependency-free: questionario per tipo di
domanda o testo+firma, commento, upload); dettaglio attività in stakeholder-activities.html
con esiti, thread commenti e allegati.
Email disattivate (kill-switch) → i magic-link si condividono manualmente. Smoke prod OK
(access no-auth, respond+required+409, acknowledge+WRONG_TYPE, comment esterno/interno,
bad-token 404, feedback interno, auto-complete; cleanup org 151 pulita). Additivo. v1.21.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a5ff29e0da
commit
4f386faae5
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Attività stakeholder - NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260627">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260628">
|
||||
<style>
|
||||
.sa-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:12px; font-size:.92rem; line-height:1.6; }
|
||||
.sa-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
|
||||
@@ -191,12 +191,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js?v=20260627"></script>
|
||||
<script src="/js/common.js?v=20260627"></script>
|
||||
<script src="/js/api.js?v=20260628"></script>
|
||||
<script src="/js/common.js?v=20260628"></script>
|
||||
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
|
||||
<script src="/js/common-bi.js?v=20260627"></script>
|
||||
<script src="/js/i18n.js?v=20260627"></script>
|
||||
<script src="/js/help.js?v=20260627"></script>
|
||||
<script src="/js/common-bi.js?v=20260628"></script>
|
||||
<script src="/js/i18n.js?v=20260628"></script>
|
||||
<script src="/js/help.js?v=20260628"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
/*
|
||||
@@ -430,8 +430,11 @@
|
||||
el('det-body').innerHTML = '<div class="sa-empty">Caricamento…</div>';
|
||||
el('det-modal').classList.add('open');
|
||||
try {
|
||||
const a = await api.stkActGet(id);
|
||||
const [a, fb, cm, at] = await Promise.all([
|
||||
api.stkActGet(id), api.stkActFeedback(id), api.stkActComments(id), api.stkActAttachments(id)
|
||||
]);
|
||||
SA.detail = a;
|
||||
SA.feedback = fb; SA.comments = (cm && cm.comments) || []; SA.atts = (at && at.attachments) || [];
|
||||
detRender(a);
|
||||
} catch (e) { el('det-body').innerHTML = '<div class="sa-empty">Errore: ' + esc(e.message) + '</div>'; }
|
||||
}
|
||||
@@ -439,24 +442,84 @@
|
||||
|
||||
function detRender(a, links) {
|
||||
el('det-title').textContent = 'Dettaglio: ' + a.title;
|
||||
const fb = SA.feedback || { template: null, targets: [] };
|
||||
const tmap = {}; (fb.targets || []).forEach(t => { tmap[t.target_id] = t; });
|
||||
const targets = a.targets || [];
|
||||
let html = '<div style="font-size:.85rem;margin-bottom:10px;color:#374151;">Tipo: <strong>' + esc(a.type) + '</strong> · Scadenza: ' + esc(a.due_date || '—') + ' · Stato: ' + esc(a.status) + '</div>';
|
||||
|
||||
// Destinatari + esito
|
||||
if (!targets.length) {
|
||||
html += '<div class="sa-empty">Nessun destinatario assegnato. Usa "Assegna destinatari".</div>';
|
||||
} else {
|
||||
html += '<table class="sa-table"><thead><tr><th>Stakeholder</th><th>Codice</th><th>Stato</th></tr></thead><tbody>'
|
||||
+ targets.map(t => '<tr><td>' + esc(t.stakeholder_name) + '</td><td>' + esc(t.stak_code) + '</td><td><span class="tg-state tg-' + t.state + '">' + esc(t.state) + '</span></td></tr>').join('')
|
||||
html += '<table class="sa-table"><thead><tr><th>Stakeholder</th><th>Codice</th><th>Stato</th><th>Esito</th></tr></thead><tbody>'
|
||||
+ targets.map(t => {
|
||||
const ft = tmap[t.id] || {};
|
||||
let outcome = '—';
|
||||
if (ft.acknowledged_at) outcome = 'Firmato il ' + esc((ft.acknowledged_at || '').slice(0, 16));
|
||||
else if (ft.answers) outcome = '<button class="btn btn-outline" style="padding:2px 8px;font-size:.72rem;" onclick="detAnswers(' + t.id + ')">Vedi risposte</button>';
|
||||
return '<tr><td>' + esc(t.stakeholder_name) + '</td><td>' + esc(t.stak_code) + '</td><td><span class="tg-state tg-' + t.state + '">' + esc(t.state) + '</span></td><td style="font-size:.8rem;">' + outcome + '</td></tr>';
|
||||
}).join('')
|
||||
+ '</tbody></table>';
|
||||
}
|
||||
|
||||
// Magic-link (dopo send)
|
||||
if (links && links.length) {
|
||||
html += '<div class="lnk-box"><strong>Magic-link generati</strong> (email disattivate: copia e condividi manualmente):'
|
||||
+ links.map(l => '<div class="lnk-item"><span style="flex:0 0 120px;">' + esc(l.stakeholder_name) + '</span><code id="lnk-' + l.target_id + '">' + esc(location.origin + l.magic_link) + '</code>'
|
||||
+ '<button class="btn btn-outline" style="padding:2px 8px;font-size:.72rem;" onclick="lnkCopy(' + l.target_id + ')">Copia</button></div>').join('')
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// Commenti
|
||||
html += '<h4 style="margin:16px 0 6px;font-size:.82rem;text-transform:uppercase;letter-spacing:.04em;color:#6b7280;">Commenti</h4>';
|
||||
html += (SA.comments && SA.comments.length)
|
||||
? '<div>' + SA.comments.map(c => '<div style="font-size:.84rem;padding:6px 0;border-top:1px solid #f3f4f6;"><strong>' + esc(c.author) + '</strong> <span style="color:#9ca3af;font-size:.76rem;">' + esc((c.created_at || '').slice(0, 16)) + (c.author_kind === 'external' ? ' · esterno' : '') + '</span><div>' + esc(c.body) + '</div></div>').join('') + '</div>'
|
||||
: '<div style="font-size:.82rem;color:#9ca3af;">Nessun commento.</div>';
|
||||
html += '<div style="display:flex;gap:8px;margin-top:8px;"><input type="text" id="det-comment" placeholder="Aggiungi un commento" style="flex:1 1 auto;padding:8px 10px;border:1px solid #e5e7eb;border-radius:8px;font-size:.86rem;"><button class="btn btn-outline" style="font-size:.78rem;" onclick="detComment()">Invia</button></div>';
|
||||
|
||||
// Allegati
|
||||
html += '<h4 style="margin:16px 0 6px;font-size:.82rem;text-transform:uppercase;letter-spacing:.04em;color:#6b7280;">Allegati</h4>';
|
||||
html += (SA.atts && SA.atts.length)
|
||||
? '<ul style="list-style:none;padding:0;margin:0;">' + SA.atts.map(f => '<li style="font-size:.84rem;padding:4px 0;"><a href="' + esc(f.url) + '" target="_blank" rel="noopener">' + esc(f.file_name) + '</a> <span style="color:#9ca3af;font-size:.76rem;">(' + Math.round((f.file_size || 0) / 1024) + ' KB)</span></li>').join('') + '</ul>'
|
||||
: '<div style="font-size:.82rem;color:#9ca3af;">Nessun allegato.</div>';
|
||||
html += '<div style="margin-top:8px;"><input type="file" id="det-file"> <button class="btn btn-outline" style="font-size:.78rem;" onclick="detUpload()">Carica allegato</button></div>';
|
||||
|
||||
el('det-body').innerHTML = html;
|
||||
}
|
||||
|
||||
function detAnswers(targetId) {
|
||||
const fb = SA.feedback || {}; const t = (fb.targets || []).find(x => x.target_id === targetId);
|
||||
if (!t || !t.answers) { showNotification('Nessuna risposta.', 'info'); return; }
|
||||
const qs = (fb.template && fb.template.questions) || [];
|
||||
const label = {}; qs.forEach(q => { label[q.code] = q.text; });
|
||||
let txt = 'Risposte di ' + (t.respondent_name || t.stakeholder_name) + ':\n\n';
|
||||
Object.keys(t.answers).forEach(k => { txt += (label[k] || k) + '\n→ ' + (Array.isArray(t.answers[k]) ? t.answers[k].join(', ') : t.answers[k]) + '\n\n'; });
|
||||
alert(txt);
|
||||
}
|
||||
|
||||
async function detComment() {
|
||||
const v = (el('det-comment') || {}).value || '';
|
||||
if (!v.trim()) return;
|
||||
try { await api.stkActAddComment(SA.detail.id, { body: v }); SA.comments = ((await api.stkActComments(SA.detail.id)).comments) || []; detRender(SA.detail); }
|
||||
catch (e) { showNotification(e.message || 'Errore', 'error'); }
|
||||
}
|
||||
|
||||
async function detUpload() {
|
||||
const f = el('det-file'); if (!f || !f.files || !f.files[0]) { showNotification('Seleziona un file.', 'info'); return; }
|
||||
const fd = new FormData(); fd.append('file', f.files[0]);
|
||||
try {
|
||||
const r = await fetch('/api/stakeholder-activities/' + SA.detail.id + '/attachments', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('nis2_access_token') || ''), 'X-Organization-Id': (localStorage.getItem('nis2_org_id') || '') },
|
||||
body: fd
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.success) { showNotification(j.message || 'Errore upload', 'error'); return; }
|
||||
SA.atts = ((await api.stkActAttachments(SA.detail.id)).attachments) || []; detRender(SA.detail);
|
||||
showNotification('Allegato caricato.', 'success');
|
||||
} catch (e) { showNotification('Errore upload.', 'error'); }
|
||||
}
|
||||
|
||||
function lnkCopy(tid) {
|
||||
const c = el('lnk-' + tid); if (!c) return;
|
||||
navigator.clipboard && navigator.clipboard.writeText(c.textContent).then(() => showNotification('Link copiato.', 'success'), () => {});
|
||||
|
||||
Reference in New Issue
Block a user