[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>
This commit is contained in:
DevEnv nis2-agile
2026-06-16 22:52:31 +02:00
co-authored by Claude Opus 4.8
parent 4f386faae5
commit b917d2da14
43 changed files with 321 additions and 191 deletions
@@ -122,6 +122,13 @@ try {
fwrite(STDERR, "WARN ALTER review_schedule: " . $e->getMessage() . "\n"); fwrite(STDERR, "WARN ALTER review_schedule: " . $e->getMessage() . "\n");
} }
// mig.054 — scadenza magic-link (idempotente: ignora 1060 colonna già esistente).
try {
$pdo->exec("ALTER TABLE stk_activity_targets ADD COLUMN token_expires_at DATETIME NULL DEFAULT NULL AFTER access_token_hash");
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? 0) !== 1060) { fwrite(STDERR, "WARN ALTER stk_activity_targets: " . $e->getMessage() . "\n"); }
}
$counts = []; $counts = [];
foreach (['stk_questionnaire_templates','stk_template_procedures','stk_template_misure','stk_template_requisiti', foreach (['stk_questionnaire_templates','stk_template_procedures','stk_template_misure','stk_template_requisiti',
'stk_activities','stk_activity_targets','stk_activity_procedures', 'stk_activities','stk_activity_targets','stk_activity_procedures',
@@ -285,7 +285,7 @@ class StakeholderActivityController extends BaseController
$orgId = $this->getCurrentOrgId(); $orgId = $this->getCurrentOrgId();
$b = $this->getJsonBody(); $b = $this->getJsonBody();
$a = Database::fetchOne('SELECT id, title, due_date FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); $a = Database::fetchOne('SELECT id, title, due_date, assign_mode, stak_code FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
$updates = []; $updates = [];
@@ -298,7 +298,10 @@ class StakeholderActivityController extends BaseController
if ($this->hasParam('template_id')) { $updates['template_id'] = $this->validateTemplate($b['template_id'] ?? null, $orgId); } if ($this->hasParam('template_id')) { $updates['template_id'] = $this->validateTemplate($b['template_id'] ?? null, $orgId); }
if ($this->hasParam('description')) { $updates['description'] = $this->nullableStr($b['description'] ?? null); } if ($this->hasParam('description')) { $updates['description'] = $this->nullableStr($b['description'] ?? null); }
if ($this->hasParam('assign_mode') || $this->hasParam('stak_code')) { if ($this->hasParam('assign_mode') || $this->hasParam('stak_code')) {
[$assignMode, $stakCode] = $this->validateAssign($b, $orgId); $bb = $b;
// se cambia solo lo stak_code, conserva la modalità esistente (non forzare 'individual')
if (!$this->hasParam('assign_mode')) { $bb['assign_mode'] = $a['assign_mode']; }
[$assignMode, $stakCode] = $this->validateAssign($bb, $orgId);
$updates['assign_mode'] = $assignMode; $updates['assign_mode'] = $assignMode;
$updates['stak_code'] = $assignMode === 'by_code' ? $stakCode : null; $updates['stak_code'] = $assignMode === 'by_code' ? $stakCode : null;
} }
@@ -358,6 +361,7 @@ class StakeholderActivityController extends BaseController
$raw = $b['stakeholder_ids'] ?? null; $raw = $b['stakeholder_ids'] ?? null;
if (!is_array($raw) || !$raw) { $this->jsonError('Seleziona almeno uno stakeholder', 422, 'NO_TARGETS'); } if (!is_array($raw) || !$raw) { $this->jsonError('Seleziona almeno uno stakeholder', 422, 'NO_TARGETS'); }
$ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0))); $ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0)));
if (!$ids) { $this->jsonError('Seleziona almeno uno stakeholder valido', 422, 'NO_TARGETS'); }
$place = implode(',', array_fill(0, count($ids), '?')); $place = implode(',', array_fill(0, count($ids), '?'));
$stakeholderIds = $this->valCol( $stakeholderIds = $this->valCol(
"SELECT id FROM stakeholders WHERE organization_id = ? AND id IN ($place)", "SELECT id FROM stakeholders WHERE organization_id = ? AND id IN ($place)",
@@ -367,6 +371,18 @@ class StakeholderActivityController extends BaseController
} }
if (!$stakeholderIds) { $this->jsonError('Nessuno stakeholder da assegnare per questo criterio', 422, 'EMPTY_TARGETS'); } if (!$stakeholderIds) { $this->jsonError('Nessuno stakeholder da assegnare per questo criterio', 422, 'EMPTY_TARGETS'); }
$stakeholderIds = array_map('intval', $stakeholderIds);
// Modalità individuale = sincronizzazione "replace": rimuovi i destinatari deselezionati
// che NON hanno ancora risposto/firmato (gli esiti raccolti non si perdono).
if ($a['assign_mode'] === 'individual') {
$keep = implode(',', array_fill(0, count($stakeholderIds), '?'));
Database::query(
"DELETE FROM stk_activity_targets
WHERE activity_id = ? AND state NOT IN ('responded','acknowledged') AND stakeholder_id NOT IN ($keep)",
array_merge([$id], $stakeholderIds)
);
}
$added = 0; $added = 0;
foreach ($stakeholderIds as $sid) { foreach ($stakeholderIds as $sid) {
try { try {
@@ -393,13 +409,20 @@ class StakeholderActivityController extends BaseController
$a = Database::fetchOne('SELECT id, due_date, title FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); $a = Database::fetchOne('SELECT id, due_date, title FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
// Solo i destinatari NON ancora conclusi: NON rigenerare token né azzerare lo stato
// di chi ha già risposto/firmato (altrimenti se ne perderebbe l'esito).
$targets = Database::fetchAll( $targets = Database::fetchAll(
'SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.contact_email, g.access_token_hash "SELECT g.id, g.stakeholder_id, s.name AS stakeholder_name, s.contact_email, g.access_token_hash
FROM stk_activity_targets g JOIN stakeholders s ON s.id = g.stakeholder_id FROM stk_activity_targets g JOIN stakeholders s ON s.id = g.stakeholder_id
WHERE g.activity_id = ?', WHERE g.activity_id = ? AND g.state NOT IN ('responded','acknowledged')",
[$id] [$id]
); );
if (!$targets) { $this->jsonError('Nessun destinatario: assegna prima gli stakeholder', 422, 'NO_TARGETS'); } if (!$targets) { $this->jsonError('Nessun destinatario da inviare (assegna stakeholder o tutti hanno già risposto)', 422, 'NO_TARGETS'); }
// Scadenza del magic-link: scadenza attività + 30gg, altrimenti 90gg da oggi.
$expires = $a['due_date']
? date('Y-m-d H:i:s', strtotime($a['due_date'] . ' +30 days'))
: date('Y-m-d H:i:s', strtotime('+90 days'));
$links = []; $links = [];
foreach ($targets as $g) { foreach ($targets as $g) {
@@ -408,6 +431,7 @@ class StakeholderActivityController extends BaseController
'access_token_hash' => hash('sha256', $token), 'access_token_hash' => hash('sha256', $token),
'state' => 'sent', 'state' => 'sent',
'sent_at' => date('Y-m-d H:i:s'), 'sent_at' => date('Y-m-d H:i:s'),
'token_expires_at' => $expires,
], 'id = ?', [(int) $g['id']]); ], 'id = ?', [(int) $g['id']]);
$links[] = [ $links[] = [
'target_id' => (int) $g['id'], 'target_id' => (int) $g['id'],
@@ -563,12 +587,13 @@ class StakeholderActivityController extends BaseController
if ($file['size'] > 10 * 1024 * 1024) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); } if ($file['size'] > 10 * 1024 * 1024) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); }
$ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($file['name'], PATHINFO_EXTENSION))); $ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($file['name'], PATHINFO_EXTENSION)));
$blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess']; // ALLOWLIST (niente html/svg/js eseguibili same-origin): allineata a StakeholderPortalController::ALLOWED_EXT
if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); } $allowed = ['pdf','png','jpg','jpeg','gif','webp','txt','csv','xlsx','xls','docx','doc','pptx','ppt','odt','ods','zip'];
if ($ext === '' || !in_array($ext, $allowed, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); }
$uploadDir = UPLOAD_PATH . "/stk_activity/{$orgId}"; $uploadDir = UPLOAD_PATH . "/stk_activity/{$orgId}";
if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); }
$filename = uniqid('sa_') . '.' . $ext; $filename = 'sa_' . bin2hex(random_bytes(16)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $filename)) { if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $filename)) {
$this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR'); $this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR');
} }
@@ -18,12 +18,19 @@
*/ */
require_once __DIR__ . '/BaseController.php'; require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/RateLimitService.php';
class StakeholderPortalController extends BaseController class StakeholderPortalController extends BaseController
{ {
// Estensioni consentite per gli allegati (ALLOWLIST: niente html/svg/js eseguibili same-origin)
private const ALLOWED_EXT = ['pdf','png','jpg','jpeg','gif','webp','txt','csv','xlsx','xls','docx','doc','pptx','ppt','odt','ods','zip'];
private const RL_READ = [['max' => 30, 'window_seconds' => 60], ['max' => 200, 'window_seconds' => 3600]];
private const RL_WRITE = [['max' => 10, 'window_seconds' => 60], ['max' => 60, 'window_seconds' => 3600]];
/** GET /api/stakeholder-portal/access?t=<token> */ /** GET /api/stakeholder-portal/access?t=<token> */
public function access(): void public function access(): void
{ {
$this->rateLimit('read');
$tg = $this->resolveTarget(); $tg = $this->resolveTarget();
$template = $this->loadTemplate($tg); $template = $this->loadTemplate($tg);
$resp = Database::fetchOne( $resp = Database::fetchOne(
@@ -52,6 +59,7 @@ class StakeholderPortalController extends BaseController
/** POST /api/stakeholder-portal/respond Body: {t*, answers*, respondent_name?} */ /** POST /api/stakeholder-portal/respond Body: {t*, answers*, respondent_name?} */
public function respond(): void public function respond(): void
{ {
$this->rateLimit('write');
$tg = $this->resolveTarget(); $tg = $this->resolveTarget();
if ($tg['type'] === 'read_ack') { $this->jsonError('Questa attività richiede una firma di avvenuta lettura, non un questionario.', 422, 'WRONG_TYPE'); } if ($tg['type'] === 'read_ack') { $this->jsonError('Questa attività richiede una firma di avvenuta lettura, non un questionario.', 422, 'WRONG_TYPE'); }
if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Risposta già inviata: non è più modificabile.', 409, 'ALREADY_SUBMITTED'); } if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Risposta già inviata: non è più modificabile.', 409, 'ALREADY_SUBMITTED'); }
@@ -83,6 +91,7 @@ class StakeholderPortalController extends BaseController
/** POST /api/stakeholder-portal/acknowledge Body: {t*, respondent_name?} */ /** POST /api/stakeholder-portal/acknowledge Body: {t*, respondent_name?} */
public function acknowledge(): void public function acknowledge(): void
{ {
$this->rateLimit('write');
$tg = $this->resolveTarget(); $tg = $this->resolveTarget();
if ($tg['type'] !== 'read_ack') { $this->jsonError('Questa attività è un questionario da compilare.', 422, 'WRONG_TYPE'); } if ($tg['type'] !== 'read_ack') { $this->jsonError('Questa attività è un questionario da compilare.', 422, 'WRONG_TYPE'); }
if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Firma già registrata.', 409, 'ALREADY_SUBMITTED'); } if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Firma già registrata.', 409, 'ALREADY_SUBMITTED'); }
@@ -98,7 +107,9 @@ class StakeholderPortalController extends BaseController
/** POST /api/stakeholder-portal/comment Body: {t*, body*} */ /** POST /api/stakeholder-portal/comment Body: {t*, body*} */
public function comment(): void public function comment(): void
{ {
$this->rateLimit('write');
$tg = $this->resolveTarget(); $tg = $this->resolveTarget();
$this->assertWritable($tg);
$body = trim((string) ($this->getJsonBody()['body'] ?? '')); $body = trim((string) ($this->getJsonBody()['body'] ?? ''));
if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); } if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); }
Database::insert('stk_activity_comments', [ Database::insert('stk_activity_comments', [
@@ -113,20 +124,21 @@ class StakeholderPortalController extends BaseController
/** POST /api/stakeholder-portal/attachment?t=<token> (multipart: file) */ /** POST /api/stakeholder-portal/attachment?t=<token> (multipart: file) */
public function attachment(): void public function attachment(): void
{ {
$this->rateLimit('write');
$tg = $this->resolveTarget(); $tg = $this->resolveTarget();
$this->assertWritable($tg);
if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$this->jsonError('File non fornito', 400, 'NO_FILE'); $this->jsonError('File non fornito', 400, 'NO_FILE');
} }
$file = $_FILES['file']; $file = $_FILES['file'];
if ($file['size'] > 10 * 1024 * 1024) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); } if ($file['size'] > 10 * 1024 * 1024) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); }
$ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($file['name'], PATHINFO_EXTENSION))); $ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($file['name'], PATHINFO_EXTENSION)));
$blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess']; if ($ext === '' || !in_array($ext, self::ALLOWED_EXT, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); }
if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); }
$orgId = (int) $tg['organization_id']; $orgId = (int) $tg['organization_id'];
$dir = UPLOAD_PATH . "/stk_activity/{$orgId}"; $dir = UPLOAD_PATH . "/stk_activity/{$orgId}";
if (!is_dir($dir)) { mkdir($dir, 0755, true); } if (!is_dir($dir)) { mkdir($dir, 0755, true); }
$filename = uniqid('sa_') . '.' . $ext; $filename = 'sa_' . bin2hex(random_bytes(16)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { $this->jsonError('Errore caricamento', 500, 'UPLOAD_ERROR'); } if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { $this->jsonError('Errore caricamento', 500, 'UPLOAD_ERROR'); }
Database::insert('evidence_files', [ Database::insert('evidence_files', [
'organization_id' => $orgId, 'organization_id' => $orgId,
@@ -153,8 +165,8 @@ class StakeholderPortalController extends BaseController
$this->jsonError('Link di accesso mancante o non valido.', 401, 'MISSING_TOKEN'); $this->jsonError('Link di accesso mancante o non valido.', 401, 'MISSING_TOKEN');
} }
$row = Database::fetchOne( $row = Database::fetchOne(
'SELECT g.id AS target_id, g.activity_id, g.state, s.name AS stakeholder_name, 'SELECT g.id AS target_id, g.activity_id, g.state, g.token_expires_at, s.name AS stakeholder_name,
a.organization_id, a.title, a.type, a.description, a.template_id a.organization_id, a.title, a.type, a.description, a.template_id, a.status AS activity_status
FROM stk_activity_targets g FROM stk_activity_targets g
JOIN stk_activities a ON a.id = g.activity_id JOIN stk_activities a ON a.id = g.activity_id
JOIN stakeholders s ON s.id = g.stakeholder_id JOIN stakeholders s ON s.id = g.stakeholder_id
@@ -162,9 +174,39 @@ class StakeholderPortalController extends BaseController
[hash('sha256', $token)] [hash('sha256', $token)]
); );
if (!$row) { $this->jsonError('Link di accesso non valido o scaduto.', 404, 'INVALID_TOKEN'); } if (!$row) { $this->jsonError('Link di accesso non valido o scaduto.', 404, 'INVALID_TOKEN'); }
if (!empty($row['token_expires_at']) && strtotime($row['token_expires_at']) < time()) {
$this->jsonError('Link di accesso scaduto. Richiedi un nuovo invio all\'organizzazione.', 410, 'TOKEN_EXPIRED');
}
return $row; return $row;
} }
/** Le scritture (commenti/allegati) sono bloccate se l'attività è chiusa. */
private function assertWritable(array $tg): void
{
if (in_array($tg['activity_status'] ?? '', ['completed', 'cancelled'], true)) {
$this->jsonError('Attività chiusa: non sono più ammessi contributi.', 409, 'ACTIVITY_CLOSED');
}
}
/** Rate limit per-IP sugli endpoint pubblici del portale. */
private function rateLimit(string $kind): void
{
$ip = $this->getClientIP();
$limits = $kind === 'write' ? self::RL_WRITE : self::RL_READ;
RateLimitService::check("stkp_{$kind}_ip:" . $ip, $limits);
RateLimitService::increment("stkp_{$kind}_ip:" . $ip);
}
private function getClientIP(): string
{
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
if ($xff !== '') {
$first = trim(explode(',', $xff)[0]);
if (filter_var($first, FILTER_VALIDATE_IP)) { return $first; }
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
private function loadTemplate(array $tg): ?array private function loadTemplate(array $tg): ?array
{ {
if (empty($tg['template_id'])) { return null; } if (empty($tg['template_id'])) { return null; }
+17
View File
@@ -0,0 +1,17 @@
-- =====================================================================
-- 054 — Scadenza dei magic-link del portale stakeholder (Epic C / C5.2 hardening)
-- =====================================================================
-- Aggiunge stk_activity_targets.token_expires_at: i magic-link generati da
-- StakeholderActivityController::send() ricevono una scadenza (scadenza attività
-- +30gg, altrimenti +90gg). StakeholderPortalController::resolveTarget() rifiuta
-- i token scaduti (410 TOKEN_EXPIRED). Mitiga il riuso indefinito di un link
-- eventualmente trapelato (finding flotta di verifica C5).
--
-- Additivo, reversibile. Runner-safe: ALTER nuda; idempotenza garantita dal
-- try/catch su errno 1060 nel CLI seed_stakeholder_activities.php.
-- =====================================================================
ALTER TABLE stk_activity_targets ADD COLUMN token_expires_at DATETIME NULL DEFAULT NULL AFTER access_token_hash;
-- ROLLBACK (manuale):
-- ALTER TABLE stk_activity_targets DROP COLUMN token_expires_at
+3 -3
View File
@@ -70,9 +70,9 @@
</script> </script>
<!-- Stessa logica della app: api.js + common.js (helper) + common-bi.js (override BI) --> <!-- Stessa logica della app: api.js + common.js (helper) + common-bi.js (override BI) -->
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
<script src="js/common-bi.js?v=20260628"></script> <script src="js/common-bi.js?v=20260629"></script>
<script> <script>
// Renderizza la sidebar BI (loadSidebar è stato ridefinito da common-bi.js) // Renderizza la sidebar BI (loadSidebar è stato ridefinito da common-bi.js)
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
+4 -4
View File
@@ -1088,10 +1088,10 @@
</main> </main>
</div> </div>
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
<script src="js/i18n.js?v=20260628"></script> <script src="js/i18n.js?v=20260629"></script>
<script src="js/help.js?v=20260628"></script> <script src="js/help.js?v=20260629"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
+5 -5
View File
@@ -154,8 +154,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -165,9 +165,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+5 -5
View File
@@ -361,8 +361,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -372,9 +372,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+5 -5
View File
@@ -349,14 +349,14 @@
</main> </main>
</div> </div>
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle + override sidebar BI (common-bi.js dopo common.js: ridefinisce solo loadSidebar) --> <!-- Bootstrap Italia bundle + override sidebar BI (common-bi.js dopo common.js: ridefinisce solo loadSidebar) -->
<script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script>if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }</script> <script>if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }</script>
<script src="js/common-bi.js?v=20260628"></script> <script src="js/common-bi.js?v=20260629"></script>
<script src="js/i18n.js?v=20260628"></script> <script src="js/i18n.js?v=20260629"></script>
<script src="js/help.js?v=20260628"></script> <script src="js/help.js?v=20260629"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+5 -5
View File
@@ -183,12 +183,12 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script src="/js/competenze.js?v=20260617"></script> <script src="/js/competenze.js?v=20260617"></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -382,8 +382,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -393,8 +393,8 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script> <script>
const SPRITE = '/vendor/bootstrap-italia/dist/svg/sprites.svg'; const SPRITE = '/vendor/bootstrap-italia/dist/svg/sprites.svg';
function ico(name, cls) { return `<svg class="ico ${cls||''}" aria-hidden="true"><use href="${SPRITE}#${name}"></use></svg>`; } function ico(name, cls) { return `<svg class="ico ${cls||''}" aria-hidden="true"><use href="${SPRITE}#${name}"></use></svg>`; }
+5 -5
View File
@@ -143,8 +143,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -154,9 +154,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+1 -1
View File
@@ -73,7 +73,7 @@
</script> </script>
<!-- Stessa logica della forgot-password.html: ZERO modifiche backend --> <!-- Stessa logica della forgot-password.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script> <script>
const form = document.getElementById('forgot-form'); const form = document.getElementById('forgot-form');
const err = document.getElementById('err'); const err = document.getElementById('err');
+5 -5
View File
@@ -1142,8 +1142,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (accordion) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (accordion) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. --> common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
@@ -1152,9 +1152,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// La guida è accessibile anche da non autenticati (utile per onboarding) // La guida è accessibile anche da non autenticati (utile per onboarding)
// ma se sei loggato carichi sidebar + i18n normalmente. Stessa logica // ma se sei loggato carichi sidebar + i18n normalmente. Stessa logica
+5 -5
View File
@@ -351,8 +351,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -362,9 +362,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth & Init ────────────────────────────────────────────── // ── Auth & Init ──────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+2 -2
View File
@@ -910,8 +910,8 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
</main> </main>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
<script src="js/i18n.js?v=20260628"></script> <script src="js/i18n.js?v=20260629"></script>
<script> <script>
function showTab(id) { function showTab(id) {
document.querySelectorAll('.tab-btn').forEach((b, i) => { document.querySelectorAll('.tab-btn').forEach((b, i) => {
+5 -5
View File
@@ -184,8 +184,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -195,9 +195,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script src="/js/isms.js"></script> <script src="/js/isms.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -204,6 +204,7 @@ function loadSidebar() {
{ name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' }, { name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' },
{ name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' }, { name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' },
{ name: 'Stakeholder', href: 'stakeholders.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"/><path d="M5.5 7.5a2 2 0 11-2.999.001A2 2 0 015.5 7.5zM17.5 7.5a2 2 0 11-2.999.001A2 2 0 0117.5 7.5z"/></svg>`, i18nKey: 'nav.stakeholders' }, { name: 'Stakeholder', href: 'stakeholders.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"/><path d="M5.5 7.5a2 2 0 11-2.999.001A2 2 0 015.5 7.5zM17.5 7.5a2 2 0 11-2.999.001A2 2 0 0117.5 7.5z"/></svg>`, i18nKey: 'nav.stakeholders' },
{ name: 'Attività stakeholder', href: 'stakeholder-activities.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd"/></svg>`, i18nKey: 'nav.stk_activities' },
{ name: 'Segnalazioni', href: 'whistleblowing.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>` }, { name: 'Segnalazioni', href: 'whistleblowing.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>` },
{ name: 'Normative', href: 'normative.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>` }, { name: 'Normative', href: 'normative.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>` },
{ name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/></svg>` }, { name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/></svg>` },
+5 -5
View File
@@ -151,8 +151,8 @@
</div> </div>
<!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). --> <!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). -->
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. --> common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI. -->
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
@@ -161,9 +161,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script src="/js/kb.js"></script> <script src="/js/kb.js"></script>
<script> <script>
// Gate auth + chrome come le altre pagine -bi. // Gate auth + chrome come le altre pagine -bi.
+1 -1
View File
@@ -515,7 +515,7 @@ body { background: var(--bg-main); }
<div id="toast"></div> <div id="toast"></div>
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script> <script>
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// CONFIG // CONFIG
+2 -2
View File
@@ -94,8 +94,8 @@
</script> </script>
<!-- Stessa logica della login attuale: ZERO modifiche backend --> <!-- Stessa logica della login attuale: ZERO modifiche backend -->
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
<script> <script>
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; } if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
+6 -6
View File
@@ -6,7 +6,7 @@
<title>Misure e Requisiti - NIS2 Agile</title> <title>Misure e Requisiti - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. --> <!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260628"> <link rel="stylesheet" href="/css/style.css?v=20260629">
<style> <style>
.mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:14px; font-size:.92rem; line-height:1.6; } .mr-intro { background:#eff6ff; border-left:4px solid var(--primary,#2563eb); padding:14px 18px; border-radius:8px; margin-bottom:14px; font-size:.92rem; line-height:1.6; }
.mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; } .mr-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
@@ -162,12 +162,12 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
+1 -1
View File
@@ -424,6 +424,6 @@ curl https://nis2.agile.software/api/services/status</pre>
</div> </div>
</div> </div>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
</body> </body>
</html> </html>
+5 -5
View File
@@ -112,8 +112,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -123,9 +123,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
+2 -2
View File
@@ -494,8 +494,8 @@
</script> </script>
<!-- Stessa logica della onboarding.html: ZERO modifiche backend --> <!-- Stessa logica della onboarding.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
<script> <script>
// ── Auth check ────────────────────────────────────────────────── // ── Auth check ──────────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+5 -5
View File
@@ -138,12 +138,12 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script src="/js/organigramma.js?v=20260617"></script> <script src="/js/organigramma.js?v=20260617"></script>
</body> </body>
</html> </html>
+5 -5
View File
@@ -333,8 +333,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -344,9 +344,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+5 -5
View File
@@ -131,12 +131,12 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script src="/js/raci.js?v=20260619"></script> <script src="/js/raci.js?v=20260619"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -268,8 +268,8 @@
</script> </script>
<!-- Stessa logica della register.html: ZERO modifiche backend --> <!-- Stessa logica della register.html: ZERO modifiche backend -->
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
<script> <script>
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; } if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
+5 -5
View File
@@ -443,8 +443,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -454,9 +454,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+5 -5
View File
@@ -141,12 +141,12 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
+5 -5
View File
@@ -494,8 +494,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -505,9 +505,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth & Init ────────────────────────────────────────────── // ── Auth & Init ──────────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+5 -5
View File
@@ -672,8 +672,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -683,9 +683,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+2 -2
View File
@@ -165,8 +165,8 @@
</main> </main>
</div> </div>
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
<script> <script>
// ── Auth check ─────────────────────────────────────────── // ── Auth check ───────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+56 -33
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Attività stakeholder - NIS2 Agile</title> <title>Attività stakeholder - NIS2 Agile</title>
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260628"> <link rel="stylesheet" href="/css/style.css?v=20260629">
<style> <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-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; } .sa-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; }
@@ -86,19 +86,19 @@
obblighi ai sensi dell'<strong>art. 24 D.Lgs. 138/2024</strong>. L'invio email è disattivato: i questionari si condividono tramite magic-link. obblighi ai sensi dell'<strong>art. 24 D.Lgs. 138/2024</strong>. L'invio email è disattivato: i questionari si condividono tramite magic-link.
</div> </div>
<div class="sa-tabs"> <div class="sa-tabs" role="tablist" aria-label="Sezioni attività">
<button class="sa-tab active" id="tab-act" onclick="saTab('act')">Attività</button> <button class="sa-tab active" id="tab-act" role="tab" aria-selected="true" aria-controls="pane-act" onclick="saTab('act')">Attività</button>
<button class="sa-tab" id="tab-tpl" onclick="saTab('tpl')">Questionari tipo</button> <button class="sa-tab" id="tab-tpl" role="tab" aria-selected="false" aria-controls="pane-tpl" onclick="saTab('tpl')">Questionari tipo</button>
</div> </div>
<!-- ATTIVITÀ --> <!-- ATTIVITÀ -->
<section id="pane-act"> <section id="pane-act" role="tabpanel" aria-labelledby="tab-act">
<div class="sa-toolbar"><button class="btn btn-primary btn-sm" onclick="actOpen()">+ Nuova attività</button></div> <div class="sa-toolbar"><button class="btn btn-primary btn-sm" onclick="actOpen()">+ Nuova attività</button></div>
<div id="act-list" aria-live="polite"></div> <div id="act-list" aria-live="polite"></div>
</section> </section>
<!-- QUESTIONARI TIPO --> <!-- QUESTIONARI TIPO -->
<section id="pane-tpl" class="hidden"> <section id="pane-tpl" class="hidden" role="tabpanel" aria-labelledby="tab-tpl">
<div class="sa-toolbar"><button class="btn btn-primary btn-sm" onclick="tplOpen()">+ Nuovo questionario tipo</button></div> <div class="sa-toolbar"><button class="btn btn-primary btn-sm" onclick="tplOpen()">+ Nuovo questionario tipo</button></div>
<div id="tpl-list" aria-live="polite"></div> <div id="tpl-list" aria-live="polite"></div>
</section> </section>
@@ -107,9 +107,9 @@
</div> </div>
<!-- Modale questionario tipo --> <!-- Modale questionario tipo -->
<div class="sm-overlay" id="tpl-modal" role="dialog" aria-modal="true"> <div class="sm-overlay" id="tpl-modal" role="dialog" aria-modal="true" aria-labelledby="tpl-modal-title">
<div class="sm-dialog"> <div class="sm-dialog">
<div class="sm-head"><h3 id="tpl-modal-title">Nuovo questionario tipo</h3><button class="sm-close" type="button" onclick="tplClose()">&times;</button></div> <div class="sm-head"><h3 id="tpl-modal-title">Nuovo questionario tipo</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="tplClose()">&times;</button></div>
<div class="sm-body"> <div class="sm-body">
<input type="hidden" id="tpl-id"> <input type="hidden" id="tpl-id">
<div class="sm-row"> <div class="sm-row">
@@ -140,9 +140,9 @@
</div> </div>
<!-- Modale attività --> <!-- Modale attività -->
<div class="sm-overlay" id="act-modal" role="dialog" aria-modal="true"> <div class="sm-overlay" id="act-modal" role="dialog" aria-modal="true" aria-labelledby="act-modal-title">
<div class="sm-dialog"> <div class="sm-dialog">
<div class="sm-head"><h3 id="act-modal-title">Nuova attività</h3><button class="sm-close" type="button" onclick="actClose()">&times;</button></div> <div class="sm-head"><h3 id="act-modal-title">Nuova attività</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="actClose()">&times;</button></div>
<div class="sm-body"> <div class="sm-body">
<input type="hidden" id="act-id"> <input type="hidden" id="act-id">
<div class="sm-row"> <div class="sm-row">
@@ -179,9 +179,9 @@
</div> </div>
<!-- Modale dettaglio attività (target + invio) --> <!-- Modale dettaglio attività (target + invio) -->
<div class="sm-overlay" id="det-modal" role="dialog" aria-modal="true"> <div class="sm-overlay" id="det-modal" role="dialog" aria-modal="true" aria-labelledby="det-title">
<div class="sm-dialog"> <div class="sm-dialog">
<div class="sm-head"><h3 id="det-title">Dettaglio attività</h3><button class="sm-close" type="button" onclick="detClose()">&times;</button></div> <div class="sm-head"><h3 id="det-title">Dettaglio attività</h3><button class="sm-close" type="button" aria-label="Chiudi" onclick="detClose()">&times;</button></div>
<div class="sm-body" id="det-body"></div> <div class="sm-body" id="det-body"></div>
<div class="sm-foot"> <div class="sm-foot">
<button class="btn btn-outline" type="button" onclick="detClose()">Chiudi</button> <button class="btn btn-outline" type="button" onclick="detClose()">Chiudi</button>
@@ -191,12 +191,12 @@
</div> </div>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
@@ -205,6 +205,8 @@
* con magic-link (email disattivate). Sotto-dashboard feedback completa in C5.2b. * con magic-link (email disattivate). Sotto-dashboard feedback completa in C5.2b.
*/ */
let SA = { activities: [], templates: [], pickers: { policies: [], misure: [], requisiti: [] }, types: [], stakeholders: [], detail: null }; let SA = { activities: [], templates: [], pickers: { policies: [], misure: [], requisiti: [] }, types: [], stakeholders: [], detail: null };
const ST_LABELS = { draft: 'Bozza', scheduled: 'Pianificata', sent: 'Inviata', in_progress: 'In corso', completed: 'Completata', cancelled: 'Annullata' };
const TYPE_LABELS = { questionnaire: 'Questionario', read_ack: 'Firma lettura', action: 'Azione' };
function el(id) { return document.getElementById(id); } function el(id) { return document.getElementById(id); }
function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; } function esc(s) { const d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
@@ -222,6 +224,8 @@
function saTab(which) { function saTab(which) {
el('tab-act').classList.toggle('active', which === 'act'); el('tab-act').classList.toggle('active', which === 'act');
el('tab-tpl').classList.toggle('active', which === 'tpl'); el('tab-tpl').classList.toggle('active', which === 'tpl');
el('tab-act').setAttribute('aria-selected', which === 'act' ? 'true' : 'false');
el('tab-tpl').setAttribute('aria-selected', which === 'tpl' ? 'true' : 'false');
el('pane-act').classList.toggle('hidden', which !== 'act'); el('pane-act').classList.toggle('hidden', which !== 'act');
el('pane-tpl').classList.toggle('hidden', which !== 'tpl'); el('pane-tpl').classList.toggle('hidden', which !== 'tpl');
} }
@@ -271,16 +275,22 @@
el('tpl-list').innerHTML = '<table class="sa-table"><thead><tr><th>Nome</th><th>Tipo</th><th>Domande</th><th>Collegamenti</th><th>Azioni</th></tr></thead><tbody>' + rows + '</tbody></table>'; el('tpl-list').innerHTML = '<table class="sa-table"><thead><tr><th>Nome</th><th>Tipo</th><th>Domande</th><th>Collegamenti</th><th>Azioni</th></tr></thead><tbody>' + rows + '</tbody></table>';
} }
function qAdd(text, type, required) { function qAdd(text, type, required, options) {
const allowed = ['text','yes_no','single_choice','multi_choice','scale_1_5','number']; const allowed = ['text','yes_no','single_choice','multi_choice','scale_1_5','number'];
const t = allowed.indexOf(type) !== -1 ? type : 'text'; const t = allowed.indexOf(type) !== -1 ? type : 'text';
const div = document.createElement('div'); div.className = 'q-row'; const isChoice = (t === 'single_choice' || t === 'multi_choice');
div.innerHTML = '<input type="text" placeholder="Testo della domanda" maxlength="500" value="' + esc(text || '').replace(/"/g,'&quot;') + '">' const div = document.createElement('div'); div.className = 'q-row'; div.style.flexWrap = 'wrap';
+ '<select>' + allowed.map(a => '<option value="' + a + '"' + (a === t ? ' selected' : '') + '>' + ({text:'Testo',yes_no:'Sì/No',single_choice:'Scelta singola',multi_choice:'Scelta multipla',scale_1_5:'Scala 1-5',number:'Numero'})[a] + '</option>').join('') + '</select>' div.innerHTML = '<input type="text" class="q-text-in" placeholder="Testo della domanda" maxlength="500" value="' + esc(text || '').replace(/"/g,'&quot;') + '">'
+ '<label style="font-size:.78rem;display:flex;align-items:center;gap:4px;white-space:nowrap;"><input type="checkbox"' + (required ? ' checked' : '') + '> obbl.</label>' + '<select class="q-type-in" onchange="qToggleOpts(this)">' + allowed.map(a => '<option value="' + a + '"' + (a === t ? ' selected' : '') + '>' + ({text:'Testo',yes_no:'Sì/No',single_choice:'Scelta singola',multi_choice:'Scelta multipla',scale_1_5:'Scala 1-5',number:'Numero'})[a] + '</option>').join('') + '</select>'
+ '<button type="button" class="q-del" onclick="this.parentNode.remove()">✕</button>'; + '<label style="font-size:.78rem;display:flex;align-items:center;gap:4px;white-space:nowrap;"><input type="checkbox" class="q-req-in"' + (required ? ' checked' : '') + '> obbl.</label>'
+ '<button type="button" class="q-del" onclick="this.closest(\'.q-row\').remove()">✕</button>'
+ '<input type="text" class="q-opts-in" placeholder="Opzioni separate da | (es. Sì | No | In parte)" maxlength="500" style="flex:1 1 100%;margin-top:6px;' + (isChoice ? '' : 'display:none;') + '" value="' + esc((options || []).join(' | ')).replace(/"/g,'&quot;') + '">';
el('tpl-questions').appendChild(div); el('tpl-questions').appendChild(div);
} }
function qToggleOpts(sel) {
const row = sel.closest('.q-row'); const o = row.querySelector('.q-opts-in');
o.style.display = (sel.value === 'single_choice' || sel.value === 'multi_choice') ? '' : 'none';
}
function tplOnKind() { function tplOnKind() {
const k = el('tpl-kind').value; const k = el('tpl-kind').value;
@@ -299,7 +309,7 @@
const t = await api.stkActGetTemplate(id); const t = await api.stkActGetTemplate(id);
el('tpl-name').value = t.name || ''; el('tpl-kind').value = t.kind || 'questionnaire'; el('tpl-name').value = t.name || ''; el('tpl-kind').value = t.kind || 'questionnaire';
el('tpl-descr').value = t.description || ''; el('tpl-content').value = t.content || ''; el('tpl-descr').value = t.description || ''; el('tpl-content').value = t.content || '';
(t.questions || []).forEach(q => qAdd(q.text, q.type, q.required)); (t.questions || []).forEach(q => qAdd(q.text, q.type, q.required, q.options));
selMulti('tpl-proc', t.policy_ids || []); selMulti('tpl-mis', t.misura_codes || []); selMulti('tpl-req', t.requisito_ids || []); selMulti('tpl-proc', t.policy_ids || []); selMulti('tpl-mis', t.misura_codes || []); selMulti('tpl-req', t.requisito_ids || []);
} catch (e) { el('tpl-err').textContent = e.message || 'Errore'; } } catch (e) { el('tpl-err').textContent = e.message || 'Errore'; }
} else { } else {
@@ -312,8 +322,14 @@
function collectQuestions() { function collectQuestions() {
return Array.prototype.map.call(el('tpl-questions').children, (row, i) => { return Array.prototype.map.call(el('tpl-questions').children, (row, i) => {
const inp = row.querySelector('input[type=text]'); const sel = row.querySelector('select'); const chk = row.querySelector('input[type=checkbox]'); const text = row.querySelector('.q-text-in').value.trim();
return { code: 'Q' + (i + 1), text: inp.value.trim(), type: sel.value, required: chk.checked }; const type = row.querySelector('.q-type-in').value;
const required = row.querySelector('.q-req-in').checked;
const q = { code: 'Q' + (i + 1), text: text, type: type, required: required };
if (type === 'single_choice' || type === 'multi_choice') {
q.options = (row.querySelector('.q-opts-in').value || '').split('|').map(s => s.trim()).filter(Boolean);
}
return q;
}).filter(q => q.text !== ''); }).filter(q => q.text !== '');
} }
@@ -348,10 +364,10 @@
if (!SA.activities.length) { el('act-list').innerHTML = '<div class="sa-empty">Nessuna attività. Creane una con "+ Nuova attività".</div>'; return; } if (!SA.activities.length) { el('act-list').innerHTML = '<div class="sa-empty">Nessuna attività. Creane una con "+ Nuova attività".</div>'; return; }
const rows = SA.activities.map(a => const rows = SA.activities.map(a =>
'<tr><td><strong>' + esc(a.title) + '</strong>' + (a.template_name ? '<div style="font-size:.78rem;color:#6b7280;">' + esc(a.template_name) + '</div>' : '') + '</td>' '<tr><td><strong>' + esc(a.title) + '</strong>' + (a.template_name ? '<div style="font-size:.78rem;color:#6b7280;">' + esc(a.template_name) + '</div>' : '') + '</td>'
+ '<td><span class="sa-badge k-' + a.type + '">' + ({questionnaire:'Questionario',read_ack:'Firma lettura',action:'Azione'})[a.type] + '</span></td>' + '<td><span class="sa-badge k-' + a.type + '">' + (TYPE_LABELS[a.type] || a.type) + '</span></td>'
+ '<td style="font-size:.82rem;">' + (a.assign_mode === 'by_code' ? ('Codice ' + esc(a.stak_code || '?')) : 'Singoli') + '</td>' + '<td style="font-size:.82rem;">' + (a.assign_mode === 'by_code' ? ('Codice ' + esc(a.stak_code || '?')) : 'Singoli') + '</td>'
+ '<td style="font-size:.82rem;">' + (a.due_date ? esc(a.due_date) : '—') + '</td>' + '<td style="font-size:.82rem;">' + (a.due_date ? esc(a.due_date) : '—') + '</td>'
+ '<td><span class="sa-badge s-' + a.status + '">' + esc(a.status) + '</span></td>' + '<td><span class="sa-badge s-' + a.status + '">' + esc(ST_LABELS[a.status] || a.status) + '</span></td>'
+ '<td style="font-size:.82rem;">' + a.n_done + '/' + a.n_targets + '</td>' + '<td style="font-size:.82rem;">' + a.n_done + '/' + a.n_targets + '</td>'
+ '<td style="white-space:nowrap;"><button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="detOpen(' + a.id + ')">Dettaglio</button> ' + '<td style="white-space:nowrap;"><button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="detOpen(' + a.id + ')">Dettaglio</button> '
+ '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="actOpen(' + a.id + ')">Modifica</button> ' + '<button class="btn btn-outline" style="padding:3px 8px;font-size:.74rem;" onclick="actOpen(' + a.id + ')">Modifica</button> '
@@ -445,7 +461,7 @@
const fb = SA.feedback || { template: null, targets: [] }; const fb = SA.feedback || { template: null, targets: [] };
const tmap = {}; (fb.targets || []).forEach(t => { tmap[t.target_id] = t; }); const tmap = {}; (fb.targets || []).forEach(t => { tmap[t.target_id] = t; });
const targets = a.targets || []; 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>'; let html = '<div id="det-answers"></div><div style="font-size:.85rem;margin-bottom:10px;color:#374151;">Tipo: <strong>' + esc(TYPE_LABELS[a.type] || a.type) + '</strong> · Scadenza: ' + esc(a.due_date || '—') + ' · Stato: ' + esc(ST_LABELS[a.status] || a.status) + '</div>';
// Destinatari + esito // Destinatari + esito
if (!targets.length) { if (!targets.length) {
@@ -489,12 +505,19 @@
function detAnswers(targetId) { function detAnswers(targetId) {
const fb = SA.feedback || {}; const t = (fb.targets || []).find(x => x.target_id === 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 box = el('det-answers'); if (!box) return;
if (!t || !t.answers) { box.innerHTML = ''; showNotification('Nessuna risposta.', 'info'); return; }
const qs = (fb.template && fb.template.questions) || []; const qs = (fb.template && fb.template.questions) || [];
const label = {}; qs.forEach(q => { label[q.code] = q.text; }); const label = {}; qs.forEach(q => { label[q.code] = q.text; });
let txt = 'Risposte di ' + (t.respondent_name || t.stakeholder_name) + ':\n\n'; const rows = Object.keys(t.answers).map(k => {
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'; }); const v = t.answers[k]; const sv = Array.isArray(v) ? v.join(', ') : (v == null ? '' : v);
alert(txt); return '<div style="padding:4px 0;border-top:1px solid #eef2ff;"><div style="font-weight:600;font-size:.82rem;">' + esc(label[k] || k) + '</div><div style="font-size:.86rem;">' + esc(sv || '—') + '</div></div>';
}).join('');
box.innerHTML = '<div style="background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:10px 12px;margin-bottom:12px;">'
+ '<div style="display:flex;justify-content:space-between;align-items:center;"><strong style="font-size:.85rem;">Risposte di ' + esc(t.respondent_name || t.stakeholder_name) + '</strong>'
+ '<button class="btn btn-outline" style="padding:1px 8px;font-size:.72rem;" onclick="el(\'det-answers\').innerHTML=\'\'">Chiudi</button></div>'
+ rows + '</div>';
box.scrollIntoView({ block: 'nearest' });
} }
async function detComment() { async function detComment() {
+6 -6
View File
@@ -6,7 +6,7 @@
<title>Stakeholder - NIS2 Agile</title> <title>Stakeholder - NIS2 Agile</title>
<!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. --> <!-- Bootstrap Italia v2.18.1 self-hostato (regola: MAI CDN). Caricato PRIMA di style.css. -->
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css"> <link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="/css/style.css?v=20260628"> <link rel="stylesheet" href="/css/style.css?v=20260629">
<style> <style>
.stk-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; } .stk-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; }
.stk-note { font-size:.82rem; color:#92400e; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:10px 12px; margin:0 0 16px; } .stk-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>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script> <script src="/vendor/bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js"></script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
'use strict'; 'use strict';
/* /*
+19 -4
View File
@@ -46,6 +46,7 @@
function el(id) { return document.getElementById(id); } 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 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'); } function app() { return el('pw-app'); }
async function apiGet(path) { async function apiGet(path) {
@@ -98,7 +99,7 @@
} }
function qHtml(q, i) { function qHtml(q, i) {
var code = esc(q.code || ('Q' + (i + 1))); var code = escAttr(q.code || ('Q' + (i + 1)));
var req = q.required ? ' <span class="q-req">*</span>' : ''; var req = q.required ? ' <span class="q-req">*</span>' : '';
var inner = ''; var inner = '';
if (q.type === 'yes_no') { if (q.type === 'yes_no') {
@@ -106,15 +107,15 @@
} else if (q.type === 'scale_1_5') { } 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(''); 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) { } 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="' + esc(o) + '"> ' + esc(o) + '</label>'; }).join(''); 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) { } 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="' + esc(o) + '"> ' + esc(o) + '</label>'; }).join(''); 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') { } else if (q.type === 'number') {
inner = '<input type="number" name="' + code + '">'; inner = '<input type="number" name="' + code + '">';
} else { } else {
inner = '<textarea name="' + code + '" rows="2"></textarea>'; inner = '<textarea name="' + code + '" rows="2"></textarea>';
} }
return '<div class="q-block" data-code="' + code + '" data-type="' + esc(q.type || 'text') + '"><div class="q-text">' + esc(q.text) + req + '</div>' + inner + '</div>'; 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() { function collectAnswers() {
@@ -136,6 +137,20 @@
async function doRespond() { async function doRespond() {
var answers = collectAnswers(); 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 }); 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; } if (!res.success) { flash(res.message || 'Errore', false); return; }
DATA.submitted = true; render(); window.scrollTo(0, 0); DATA.submitted = true; render(); window.scrollTo(0, 0);
+5 -5
View File
@@ -477,8 +477,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -488,9 +488,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+1 -1
View File
@@ -13,7 +13,7 @@
* Il nome cache include la release: va BUMPATO ad ogni rilascio (vedi version.json), * Il nome cache include la release: va BUMPATO ad ogni rilascio (vedi version.json),
* cosi' activate elimina automaticamente la shell vecchia. * cosi' activate elimina automaticamente la shell vecchia.
*/ */
const CACHE = 'nis2-shell-v1.21.0'; const CACHE = 'nis2-shell-v1.21.1';
const PRECACHE = [ const PRECACHE = [
'/offline.html', '/offline.html',
+5 -5
View File
@@ -292,8 +292,8 @@
</main> </main>
</div> </div>
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -303,9 +303,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
// ── Auth & Init ───────────────────────────────────────── // ── Auth & Init ─────────────────────────────────────────
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
+1 -1
View File
@@ -1 +1 @@
{"version": "1.21.0", "build": "2026-06-16-v1.21.0", "date": "2026-06-16", "changelog": "Epic C / C5.2b - Portale esterno stakeholder (token magic-link, no account/JWT) + sotto-dashboard feedback: i destinatari rispondono al questionario o firmano l'avvenuta lettura, commentano e allegano file dal portale; il compliance manager vede esiti, commenti e allegati nel dettaglio attivita'. mig.053: stk_activity_responses, stk_activity_comments (allegati su evidence_files). StakeholderPortalController (non-JWT) + StakeholderActivityController feedback/comments/attachments; stk-portal.html pubblica. Completa C5.2/Epic C. Additivo."} {"version": "1.21.1", "build": "2026-06-16-v1.21.1", "date": "2026-06-16", "changelog": "Epic C / C5 hardening (post-verifica flotta): upload allegati con ALLOWLIST estensioni (no html/svg/js same-origin) + nome file random; scadenza magic-link (mig.054 token_expires_at, 410 TOKEN_EXPIRED) + rate-limit endpoint portale + guard scritture su attivita' chiuse; send() non azzera piu' i destinatari gia' responded/acknowledged; assign individuale con semantica replace + guard lista vuota; update conserva assign_mode esistente; editor opzioni per domande a scelta singola/multipla; etichette stato localizzate; risposte mostrate inline (no alert); ARIA su modali/tab; escAttr nel portale; voce sidebar in common.js. Additivo."}
+5 -5
View File
@@ -207,8 +207,8 @@
<!-- Report Detail Modal handled by common.js showModal --> <!-- Report Detail Modal handled by common.js showModal -->
<script src="/js/api.js?v=20260628"></script> <script src="/js/api.js?v=20260629"></script>
<script src="/js/common.js?v=20260628"></script> <script src="/js/common.js?v=20260629"></script>
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js). <!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI, common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
riusando tutti gli helper di common.js (zero backend). --> riusando tutti gli helper di common.js (zero backend). -->
@@ -218,9 +218,9 @@
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
} }
</script> </script>
<script src="/js/common-bi.js?v=20260628"></script> <script src="/js/common-bi.js?v=20260629"></script>
<script src="/js/i18n.js?v=20260628"></script> <script src="/js/i18n.js?v=20260629"></script>
<script src="/js/help.js?v=20260628"></script> <script src="/js/help.js?v=20260629"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();
+3 -3
View File
@@ -319,9 +319,9 @@
</main> </main>
</div> </div>
<script src="js/api.js?v=20260628"></script> <script src="js/api.js?v=20260629"></script>
<script src="js/common.js?v=20260628"></script> <script src="js/common.js?v=20260629"></script>
<script src="js/i18n.js?v=20260628"></script> <script src="js/i18n.js?v=20260629"></script>
<script> <script>
if (!checkAuth()) throw new Error('Not authenticated'); if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar(); loadSidebar();