[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
@@ -89,6 +89,25 @@ $ddl = [
|
||||
CONSTRAINT fk_stkap_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_stkap_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
// C5.2b (mig.053) — feedback: risposte + commenti
|
||||
"CREATE TABLE IF NOT EXISTS stk_activity_responses (
|
||||
id INT NOT NULL AUTO_INCREMENT, target_id INT NOT NULL,
|
||||
answers JSON NULL, acknowledged_at DATETIME NULL,
|
||||
respondent_name VARCHAR(255) NULL, respondent_email VARCHAR(255) NULL, ip_address VARCHAR(45) NULL,
|
||||
submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id), UNIQUE KEY uq_stkar_target (target_id),
|
||||
CONSTRAINT fk_stkar_target FOREIGN KEY (target_id) REFERENCES stk_activity_targets (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS stk_activity_comments (
|
||||
id INT NOT NULL AUTO_INCREMENT, activity_id INT NOT NULL, body TEXT NOT NULL,
|
||||
author_kind ENUM('internal','external') NOT NULL DEFAULT 'internal',
|
||||
author_user_id INT NULL, author_label VARCHAR(255) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id), KEY idx_stkac_act (activity_id),
|
||||
CONSTRAINT fk_stkac_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_stkac_user FOREIGN KEY (author_user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
foreach ($ddl as $stmt) {
|
||||
try { $pdo->exec($stmt); }
|
||||
@@ -105,7 +124,8 @@ try {
|
||||
|
||||
$counts = [];
|
||||
foreach (['stk_questionnaire_templates','stk_template_procedures','stk_template_misure','stk_template_requisiti',
|
||||
'stk_activities','stk_activity_targets','stk_activity_procedures'] as $t) {
|
||||
'stk_activities','stk_activity_targets','stk_activity_procedures',
|
||||
'stk_activity_responses','stk_activity_comments'] as $t) {
|
||||
$counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn();
|
||||
}
|
||||
$enum = $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS
|
||||
|
||||
@@ -430,10 +430,160 @@ class StakeholderActivityController extends BaseController
|
||||
], 'Attività inviata (magic-link generati)');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// FEEDBACK (C5.2b) — risposte, commenti, allegati (lato interno)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/stakeholder-activities/{id}/feedback */
|
||||
public function feedback(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$a = Database::fetchOne('SELECT id, title, type, template_id FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
||||
if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); }
|
||||
|
||||
$template = null;
|
||||
if ($a['template_id']) {
|
||||
$t = Database::fetchOne('SELECT kind, content, questions FROM stk_questionnaire_templates WHERE id = ? AND organization_id = ?', [(int) $a['template_id'], $orgId]);
|
||||
if ($t) {
|
||||
$q = $t['questions'] ? json_decode($t['questions'], true) : [];
|
||||
$template = ['kind' => $t['kind'], 'content' => $t['content'], 'questions' => is_array($q) ? $q : []];
|
||||
}
|
||||
}
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT g.id AS target_id, g.state, g.sent_at, g.responded_at,
|
||||
s.name AS stakeholder_name, s.stak_code,
|
||||
r.answers, r.acknowledged_at, r.respondent_name, r.submitted_at
|
||||
FROM stk_activity_targets g
|
||||
JOIN stakeholders s ON s.id = g.stakeholder_id
|
||||
LEFT JOIN stk_activity_responses r ON r.target_id = g.id
|
||||
WHERE g.activity_id = ? ORDER BY s.name ASC',
|
||||
[$id]
|
||||
);
|
||||
$targets = array_map(static function ($r) {
|
||||
$ans = $r['answers'] ? json_decode($r['answers'], true) : null;
|
||||
return [
|
||||
'target_id' => (int) $r['target_id'], 'stakeholder_name' => $r['stakeholder_name'], 'stak_code' => $r['stak_code'],
|
||||
'state' => $r['state'], 'sent_at' => $r['sent_at'], 'responded_at' => $r['responded_at'],
|
||||
'answers' => is_array($ans) ? $ans : null, 'acknowledged_at' => $r['acknowledged_at'],
|
||||
'respondent_name' => $r['respondent_name'], 'submitted_at' => $r['submitted_at'],
|
||||
];
|
||||
}, $rows);
|
||||
$this->jsonSuccess([
|
||||
'activity' => ['id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type']],
|
||||
'template' => $template,
|
||||
'targets' => $targets,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/stakeholder-activities/{id}/comments */
|
||||
public function comments(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$this->assertActivity($id, $orgId);
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT c.id, c.body, c.author_kind, c.author_label, c.created_at, u.full_name AS author_name
|
||||
FROM stk_activity_comments c LEFT JOIN users u ON u.id = c.author_user_id
|
||||
WHERE c.activity_id = ? ORDER BY c.created_at ASC',
|
||||
[$id]
|
||||
);
|
||||
$this->jsonSuccess(['comments' => array_map(static fn($c) => [
|
||||
'id' => (int) $c['id'], 'body' => $c['body'], 'author_kind' => $c['author_kind'],
|
||||
'author' => $c['author_kind'] === 'external' ? ($c['author_label'] ?: 'Stakeholder') : ($c['author_name'] ?: 'Interno'),
|
||||
'created_at' => $c['created_at'],
|
||||
], $rows)]);
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-activities/{id}/comments Body: {body*} */
|
||||
public function addComment(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$this->assertActivity($id, $orgId);
|
||||
$body = trim((string) ($this->getJsonBody()['body'] ?? ''));
|
||||
if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); }
|
||||
$cid = Database::insert('stk_activity_comments', [
|
||||
'activity_id' => $id, 'body' => mb_substr($body, 0, 5000),
|
||||
'author_kind' => 'internal', 'author_user_id' => $this->getCurrentUserId(),
|
||||
]);
|
||||
$this->jsonSuccess(['id' => (int) $cid], 'Commento aggiunto', 201);
|
||||
}
|
||||
|
||||
/** GET /api/stakeholder-activities/{id}/attachments */
|
||||
public function attachments(int $id): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$this->assertActivity($id, $orgId);
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, file_name, file_path, file_size, mime_type, created_at
|
||||
FROM evidence_files WHERE organization_id = ? AND entity_type = 'stk_activity' AND entity_id = ?
|
||||
ORDER BY created_at DESC",
|
||||
[$orgId, $id]
|
||||
);
|
||||
$this->jsonSuccess(['attachments' => array_map(static fn($f) => [
|
||||
'id' => (int) $f['id'], 'file_name' => $f['file_name'], 'url' => '/uploads/' . $f['file_path'],
|
||||
'file_size' => (int) $f['file_size'], 'created_at' => $f['created_at'],
|
||||
], $rows)]);
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-activities/{id}/attachments (multipart: file) */
|
||||
public function uploadAttachment(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(self::MANAGE_ROLES);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$this->assertActivity($id, $orgId);
|
||||
$fid = $this->storeUpload($orgId, $id, $this->getCurrentUserId());
|
||||
$this->jsonSuccess(['id' => $fid], 'Allegato caricato', 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// HELPER
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function assertActivity(int $id, int $orgId): void
|
||||
{
|
||||
if (!Database::fetchOne('SELECT id FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId])) {
|
||||
$this->jsonError('Attività non trovata', 404, 'NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva un file caricato (campo 'file') sotto public/uploads/stk_activity/{org}/
|
||||
* e registra in evidence_files (entity_type='stk_activity'). Riusa il pattern di
|
||||
* AuditController::uploadEvidence. uploadedBy null per upload esterni dal portale.
|
||||
* Ritorna l'id evidence_files.
|
||||
*/
|
||||
public function storeUpload(int $orgId, int $activityId, ?int $uploadedBy): int
|
||||
{
|
||||
if (!isset($_FILES['file'])) { $this->jsonError('File non fornito', 400, 'NO_FILE'); }
|
||||
$file = $_FILES['file'];
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $this->jsonError('Caricamento non riuscito', 400, 'UPLOAD_ERROR'); }
|
||||
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)));
|
||||
$blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess'];
|
||||
if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); }
|
||||
|
||||
$uploadDir = UPLOAD_PATH . "/stk_activity/{$orgId}";
|
||||
if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); }
|
||||
$filename = uniqid('sa_') . '.' . $ext;
|
||||
if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $filename)) {
|
||||
$this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR');
|
||||
}
|
||||
return (int) Database::insert('evidence_files', [
|
||||
'organization_id' => $orgId,
|
||||
'entity_type' => 'stk_activity',
|
||||
'entity_id' => $activityId,
|
||||
'file_name' => mb_substr((string) $file['name'], 0, 255),
|
||||
'file_path' => "stk_activity/{$orgId}/{$filename}",
|
||||
'file_size' => (int) $file['size'],
|
||||
'mime_type' => mb_substr((string) ($file['type'] ?? ''), 0, 100),
|
||||
'uploaded_by' => $uploadedBy,
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateTemplate($id, int $orgId): ?int
|
||||
{
|
||||
$id = ($id === null || $id === '') ? null : (int) $id;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Portale esterno Stakeholder (Epic C / C5.2b)
|
||||
* ----------------------------------------------------------------------------
|
||||
* Accesso self-service degli stakeholder a un'attività (questionario da compilare
|
||||
* o firma di avvenuta lettura) tramite MAGIC-LINK per-destinatario: il token (in
|
||||
* chiaro nell'URL) viene confrontato con l'hash SHA-256 salvato su
|
||||
* stk_activity_targets.access_token_hash (mig.052). NESSUN account, NESSUN JWT.
|
||||
* Stesso spirito dei token sq_ legacy del supplier-portal.
|
||||
*
|
||||
* Poiché le email sono disattivate (kill-switch), il magic-link viene generato e
|
||||
* condiviso manualmente dal compliance manager (StakeholderActivityController::send).
|
||||
*
|
||||
* Sicurezza: il token risolve UN solo destinatario; non si espone nulla di altre
|
||||
* organizzazioni o di altri target. Submit consumabile una sola volta (409 dopo).
|
||||
*
|
||||
* NOTE: niente requireAuth. Database::query/fetchAll/fetchOne/insert/update.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class StakeholderPortalController extends BaseController
|
||||
{
|
||||
/** GET /api/stakeholder-portal/access?t=<token> */
|
||||
public function access(): void
|
||||
{
|
||||
$tg = $this->resolveTarget();
|
||||
$template = $this->loadTemplate($tg);
|
||||
$resp = Database::fetchOne(
|
||||
'SELECT answers, acknowledged_at, submitted_at FROM stk_activity_responses WHERE target_id = ?',
|
||||
[(int) $tg['target_id']]
|
||||
);
|
||||
$existing = null;
|
||||
if ($resp) {
|
||||
$ans = $resp['answers'] ? json_decode($resp['answers'], true) : null;
|
||||
$existing = [
|
||||
'answers' => is_array($ans) ? $ans : null,
|
||||
'acknowledged_at' => $resp['acknowledged_at'],
|
||||
'submitted_at' => $resp['submitted_at'],
|
||||
];
|
||||
}
|
||||
$this->jsonSuccess([
|
||||
'activity' => ['title' => $tg['title'], 'type' => $tg['type'], 'description' => $tg['description']],
|
||||
'stakeholder_name' => $tg['stakeholder_name'],
|
||||
'state' => $tg['state'],
|
||||
'template' => $template,
|
||||
'submitted' => in_array($tg['state'], ['responded', 'acknowledged'], true),
|
||||
'existing' => $existing,
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-portal/respond Body: {t*, answers*, respondent_name?} */
|
||||
public function respond(): void
|
||||
{
|
||||
$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 (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Risposta già inviata: non è più modificabile.', 409, 'ALREADY_SUBMITTED'); }
|
||||
|
||||
$b = $this->getJsonBody();
|
||||
$answers = $b['answers'] ?? null;
|
||||
if (!is_array($answers) || !$answers) { $this->jsonError('Nessuna risposta fornita', 422, 'NO_ANSWERS'); }
|
||||
|
||||
// verifica risposte obbligatorie del template (se presente)
|
||||
$template = $this->loadTemplate($tg);
|
||||
if ($template && !empty($template['questions'])) {
|
||||
$missing = [];
|
||||
foreach ($template['questions'] as $q) {
|
||||
if (!empty($q['required'])) {
|
||||
$code = $q['code'] ?? '';
|
||||
$v = $answers[$code] ?? null;
|
||||
if ($v === null || $v === '' || (is_array($v) && !$v)) { $missing[] = $code; }
|
||||
}
|
||||
}
|
||||
if ($missing) { $this->jsonError('Mancano risposte obbligatorie.', 422, 'REQUIRED_MISSING', ['missing' => $missing]); }
|
||||
}
|
||||
|
||||
$this->saveResponse((int) $tg['target_id'], json_encode($answers, JSON_UNESCAPED_UNICODE), null, $b['respondent_name'] ?? null);
|
||||
Database::update('stk_activity_targets', ['state' => 'responded', 'responded_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $tg['target_id']]);
|
||||
$this->maybeComplete((int) $tg['activity_id']);
|
||||
$this->jsonSuccess(['state' => 'responded'], 'Grazie, risposta inviata.');
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-portal/acknowledge Body: {t*, respondent_name?} */
|
||||
public function acknowledge(): void
|
||||
{
|
||||
$tg = $this->resolveTarget();
|
||||
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'); }
|
||||
|
||||
$b = $this->getJsonBody();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->saveResponse((int) $tg['target_id'], null, $now, $b['respondent_name'] ?? null);
|
||||
Database::update('stk_activity_targets', ['state' => 'acknowledged', 'responded_at' => $now], 'id = ?', [(int) $tg['target_id']]);
|
||||
$this->maybeComplete((int) $tg['activity_id']);
|
||||
$this->jsonSuccess(['state' => 'acknowledged'], 'Firma di avvenuta lettura registrata. Grazie.');
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-portal/comment Body: {t*, body*} */
|
||||
public function comment(): void
|
||||
{
|
||||
$tg = $this->resolveTarget();
|
||||
$body = trim((string) ($this->getJsonBody()['body'] ?? ''));
|
||||
if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); }
|
||||
Database::insert('stk_activity_comments', [
|
||||
'activity_id' => (int) $tg['activity_id'],
|
||||
'body' => mb_substr($body, 0, 5000),
|
||||
'author_kind' => 'external',
|
||||
'author_label'=> mb_substr((string) $tg['stakeholder_name'], 0, 255),
|
||||
]);
|
||||
$this->jsonSuccess(null, 'Commento inviato.');
|
||||
}
|
||||
|
||||
/** POST /api/stakeholder-portal/attachment?t=<token> (multipart: file) */
|
||||
public function attachment(): void
|
||||
{
|
||||
$tg = $this->resolveTarget();
|
||||
if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
$this->jsonError('File non fornito', 400, 'NO_FILE');
|
||||
}
|
||||
$file = $_FILES['file'];
|
||||
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)));
|
||||
$blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess'];
|
||||
if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); }
|
||||
|
||||
$orgId = (int) $tg['organization_id'];
|
||||
$dir = UPLOAD_PATH . "/stk_activity/{$orgId}";
|
||||
if (!is_dir($dir)) { mkdir($dir, 0755, true); }
|
||||
$filename = uniqid('sa_') . '.' . $ext;
|
||||
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { $this->jsonError('Errore caricamento', 500, 'UPLOAD_ERROR'); }
|
||||
Database::insert('evidence_files', [
|
||||
'organization_id' => $orgId,
|
||||
'entity_type' => 'stk_activity',
|
||||
'entity_id' => (int) $tg['activity_id'],
|
||||
'file_name' => mb_substr((string) $file['name'], 0, 255),
|
||||
'file_path' => "stk_activity/{$orgId}/{$filename}",
|
||||
'file_size' => (int) $file['size'],
|
||||
'mime_type' => mb_substr((string) ($file['type'] ?? ''), 0, 100),
|
||||
'uploaded_by' => null,
|
||||
]);
|
||||
$this->jsonSuccess(null, 'Allegato caricato.', 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// HELPER
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Risolve il destinatario dal token (?t= o body.t). 401 se assente, 404 se non valido. */
|
||||
private function resolveTarget(): array
|
||||
{
|
||||
$token = trim((string) ($_GET['t'] ?? $this->getJsonBody()['t'] ?? ''));
|
||||
if ($token === '' || !preg_match('/^[a-f0-9]{48}$/', $token)) {
|
||||
$this->jsonError('Link di accesso mancante o non valido.', 401, 'MISSING_TOKEN');
|
||||
}
|
||||
$row = Database::fetchOne(
|
||||
'SELECT g.id AS target_id, g.activity_id, g.state, s.name AS stakeholder_name,
|
||||
a.organization_id, a.title, a.type, a.description, a.template_id
|
||||
FROM stk_activity_targets g
|
||||
JOIN stk_activities a ON a.id = g.activity_id
|
||||
JOIN stakeholders s ON s.id = g.stakeholder_id
|
||||
WHERE g.access_token_hash = ?',
|
||||
[hash('sha256', $token)]
|
||||
);
|
||||
if (!$row) { $this->jsonError('Link di accesso non valido o scaduto.', 404, 'INVALID_TOKEN'); }
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function loadTemplate(array $tg): ?array
|
||||
{
|
||||
if (empty($tg['template_id'])) { return null; }
|
||||
$t = Database::fetchOne('SELECT kind, content, questions FROM stk_questionnaire_templates WHERE id = ?', [(int) $tg['template_id']]);
|
||||
if (!$t) { return null; }
|
||||
$q = $t['questions'] ? json_decode($t['questions'], true) : [];
|
||||
return ['kind' => $t['kind'], 'content' => $t['content'], 'questions' => is_array($q) ? $q : []];
|
||||
}
|
||||
|
||||
/** Upsert della risposta (una per target). */
|
||||
private function saveResponse(int $targetId, ?string $answersJson, ?string $ackAt, $respondentName): void
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$name = $respondentName !== null ? mb_substr(trim((string) $respondentName), 0, 255) : null;
|
||||
Database::query(
|
||||
'INSERT INTO stk_activity_responses (target_id, answers, acknowledged_at, respondent_name, ip_address, submitted_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE answers = VALUES(answers), acknowledged_at = VALUES(acknowledged_at),
|
||||
respondent_name = VALUES(respondent_name), ip_address = VALUES(ip_address), submitted_at = NOW()',
|
||||
[$targetId, $answersJson, $ackAt, $name, $ip]
|
||||
);
|
||||
}
|
||||
|
||||
/** Se tutti i destinatari hanno risposto/firmato, segna l'attività 'completed'. */
|
||||
private function maybeComplete(int $activityId): void
|
||||
{
|
||||
$tot = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ?', [$activityId])['c'];
|
||||
$done = (int) Database::fetchOne(
|
||||
"SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ? AND state IN ('responded','acknowledged')",
|
||||
[$activityId]
|
||||
)['c'];
|
||||
if ($tot > 0 && $done >= $tot) {
|
||||
Database::update('stk_activities', ['status' => 'completed'], 'id = ?', [$activityId]);
|
||||
} else {
|
||||
Database::query("UPDATE stk_activities SET status = 'in_progress' WHERE id = ? AND status = 'sent'", [$activityId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
-- =====================================================================
|
||||
-- 053 — Feedback attività stakeholder: risposte + commenti (Epic C / C5.2b)
|
||||
-- =====================================================================
|
||||
-- Sotto-dashboard di ricezione feedback (Simon C5 §4.3): per ogni destinatario
|
||||
-- (stk_activity_targets, mig.052) si raccoglie la RISPOSTA al questionario oppure
|
||||
-- la FIRMA di avvenuta lettura; sull'attività si possono aggiungere COMMENTI e
|
||||
-- ALLEGATI. Gli allegati RIUSANO evidence_files (entity_type='stk_activity',
|
||||
-- entity_id=activity_id) — nessuna tabella nuova per i file.
|
||||
--
|
||||
-- Le risposte arrivano dal portale esterno self-service (token magic-link,
|
||||
-- StakeholderPortalController, non-JWT) oppure registrate internamente.
|
||||
--
|
||||
-- Additivo, reversibile. Runner-safe: CREATE TABLE IF NOT EXISTS con FK dentro
|
||||
-- il CREATE; nessun DELIMITER; nessun ';' nei commenti. DDL eseguita dal CLI
|
||||
-- idempotente application/cli/seed_stakeholder_activities.php (estende C5.2a).
|
||||
-- =====================================================================
|
||||
|
||||
-- Risposta del destinatario (una per target): answers JSON (questionario) oppure
|
||||
-- acknowledged_at (firma di avvenuta lettura).
|
||||
CREATE TABLE IF NOT EXISTS stk_activity_responses (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
target_id INT NOT NULL,
|
||||
answers JSON NULL,
|
||||
acknowledged_at DATETIME NULL,
|
||||
respondent_name VARCHAR(255) NULL,
|
||||
respondent_email VARCHAR(255) NULL,
|
||||
ip_address VARCHAR(45) NULL,
|
||||
submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_stkar_target (target_id),
|
||||
CONSTRAINT fk_stkar_target FOREIGN KEY (target_id) REFERENCES stk_activity_targets (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Commenti append-only sull'attività (interni o esterni dal portale).
|
||||
CREATE TABLE IF NOT EXISTS stk_activity_comments (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
activity_id INT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
author_kind ENUM('internal','external') NOT NULL DEFAULT 'internal',
|
||||
author_user_id INT NULL,
|
||||
author_label VARCHAR(255) NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_stkac_act (activity_id),
|
||||
CONSTRAINT fk_stkac_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_stkac_user FOREIGN KEY (author_user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ROLLBACK (manuale):
|
||||
-- DROP TABLE IF EXISTS stk_activity_comments
|
||||
-- DROP TABLE IF EXISTS stk_activity_responses
|
||||
@@ -70,9 +70,9 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della app: api.js + common.js (helper) + common-bi.js (override BI) -->
|
||||
<script src="js/api.js?v=20260627"></script>
|
||||
<script src="js/common.js?v=20260627"></script>
|
||||
<script src="js/common-bi.js?v=20260627"></script>
|
||||
<script src="js/api.js?v=20260628"></script>
|
||||
<script src="js/common.js?v=20260628"></script>
|
||||
<script src="js/common-bi.js?v=20260628"></script>
|
||||
<script>
|
||||
// Renderizza la sidebar BI (loadSidebar è stato ridefinito da common-bi.js)
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
@@ -1088,10 +1088,10 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260627"></script>
|
||||
<script src="js/common.js?v=20260627"></script>
|
||||
<script src="js/i18n.js?v=20260627"></script>
|
||||
<script src="js/help.js?v=20260627"></script>
|
||||
<script src="js/api.js?v=20260628"></script>
|
||||
<script src="js/common.js?v=20260628"></script>
|
||||
<script src="js/i18n.js?v=20260628"></script>
|
||||
<script src="js/help.js?v=20260628"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
@@ -154,8 +154,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -165,9 +165,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+5
-5
@@ -361,8 +361,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -372,9 +372,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -349,14 +349,14 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- 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>if (window.bootstrap && bootstrap.loadFonts) { bootstrap.loadFonts('vendor/bootstrap-italia/dist/fonts'); }</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>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
|
||||
@@ -183,12 +183,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 src="/js/competenze.js?v=20260617"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -382,8 +382,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -393,8 +393,8 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</script>
|
||||
<script src="/js/common-bi.js?v=20260627"></script>
|
||||
<script src="/js/i18n.js?v=20260627"></script>
|
||||
<script src="/js/common-bi.js?v=20260628"></script>
|
||||
<script src="/js/i18n.js?v=20260628"></script>
|
||||
<script>
|
||||
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>`; }
|
||||
|
||||
@@ -143,8 +143,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -154,9 +154,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della forgot-password.html: ZERO modifiche backend -->
|
||||
<script src="js/api.js?v=20260627"></script>
|
||||
<script src="js/api.js?v=20260628"></script>
|
||||
<script>
|
||||
const form = document.getElementById('forgot-form');
|
||||
const err = document.getElementById('err');
|
||||
|
||||
+5
-5
@@ -1142,8 +1142,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (accordion) + override sidebar BI (common-bi.js).
|
||||
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>
|
||||
@@ -1152,9 +1152,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// La guida è accessibile anche da non autenticati (utile per onboarding)
|
||||
// ma se sei loggato carichi sidebar + i18n normalmente. Stessa logica
|
||||
|
||||
@@ -351,8 +351,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -362,9 +362,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth & Init ──────────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -93,6 +93,7 @@ $controllerMap = [
|
||||
'framework' => 'FrameworkController', // Epic C / C1 — Elenco canonico Misure/Requisiti (cfg_nis2_*, read-only)
|
||||
'stakeholders' => 'StakeholderController', // Epic C / C5 — Registro stakeholder + matrice di Mendelow
|
||||
'stakeholder-activities' => 'StakeholderActivityController', // Epic C / C5.2 — Attività stakeholder (questionari/azioni + calendario)
|
||||
'stakeholder-portal' => 'StakeholderPortalController', // Epic C / C5.2b — Portale esterno stakeholder (token magic-link, NO JWT)
|
||||
'assessments' => 'AssessmentController',
|
||||
'isms' => 'IsmsModelController', // Modello Organizzativo SGSI (ISO 27001/27017/27018 + SoA)
|
||||
'dashboard' => 'DashboardController',
|
||||
@@ -458,6 +459,20 @@ $actionMap = [
|
||||
'DELETE:{id}' => 'delete',
|
||||
'POST:{id}/assign' => 'assign',
|
||||
'POST:{id}/send' => 'send',
|
||||
'GET:{id}/feedback' => 'feedback',
|
||||
'GET:{id}/comments' => 'comments',
|
||||
'POST:{id}/comments' => 'addComment',
|
||||
'GET:{id}/attachments' => 'attachments',
|
||||
'POST:{id}/attachments' => 'uploadAttachment',
|
||||
],
|
||||
|
||||
// ── StakeholderPortalController (Epic C / C5.2b — portale esterno, token magic-link, NO JWT) ──
|
||||
'stakeholder-portal' => [
|
||||
'GET:access' => 'access',
|
||||
'POST:respond' => 'respond',
|
||||
'POST:acknowledge'=> 'acknowledge',
|
||||
'POST:comment' => 'comment',
|
||||
'POST:attachment' => 'attachment',
|
||||
],
|
||||
|
||||
// ── AuditController ─────────────────────────────
|
||||
|
||||
@@ -910,8 +910,8 @@ curl -H <span class="str">"X-API-Key: nis2_TUA_CHIAVE"</span> \
|
||||
|
||||
</main>
|
||||
|
||||
<script src="js/common.js?v=20260627"></script>
|
||||
<script src="js/i18n.js?v=20260627"></script>
|
||||
<script src="js/common.js?v=20260628"></script>
|
||||
<script src="js/i18n.js?v=20260628"></script>
|
||||
<script>
|
||||
function showTab(id) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||
|
||||
+5
-5
@@ -184,8 +184,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -195,9 +195,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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 src="/js/isms.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -327,6 +327,11 @@ class NIS2API {
|
||||
stkActDelete(id) { return this._acn(this.del(`/stakeholder-activities/${id}`)); }
|
||||
stkActAssign(id, d) { return this._acn(this.post(`/stakeholder-activities/${id}/assign`, d || {})); }
|
||||
stkActSend(id) { return this._acn(this.post(`/stakeholder-activities/${id}/send`, {})); }
|
||||
// C5.2b feedback (interno)
|
||||
stkActFeedback(id) { return this._acn(this.get(`/stakeholder-activities/${id}/feedback`)); }
|
||||
stkActComments(id) { return this._acn(this.get(`/stakeholder-activities/${id}/comments`)); }
|
||||
stkActAddComment(id, d) { return this._acn(this.post(`/stakeholder-activities/${id}/comments`, d || {})); }
|
||||
stkActAttachments(id) { return this._acn(this.get(`/stakeholder-activities/${id}/attachments`)); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Dashboard
|
||||
|
||||
+5
-5
@@ -151,8 +151,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Stessa logica JS della pagina live (parita' funzionale assoluta, zero backend). -->
|
||||
<script src="/js/common.js?v=20260627"></script>
|
||||
<script src="/js/api.js?v=20260627"></script>
|
||||
<script src="/js/common.js?v=20260628"></script>
|
||||
<script src="/js/api.js?v=20260628"></script>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
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>
|
||||
@@ -161,9 +161,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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 src="/js/kb.js"></script>
|
||||
<script>
|
||||
// Gate auth + chrome come le altre pagine -bi.
|
||||
|
||||
@@ -515,7 +515,7 @@ body { background: var(--bg-main); }
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script src="js/api.js?v=20260627"></script>
|
||||
<script src="js/api.js?v=20260628"></script>
|
||||
<script>
|
||||
// ══════════════════════════════════════════════════════
|
||||
// CONFIG
|
||||
|
||||
+2
-2
@@ -94,8 +94,8 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della login attuale: ZERO modifiche backend -->
|
||||
<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>
|
||||
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Misure e Requisiti - NIS2 Agile</title>
|
||||
<!-- 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="/css/style.css?v=20260627">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260628">
|
||||
<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-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>
|
||||
|
||||
<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';
|
||||
/*
|
||||
|
||||
@@ -424,6 +424,6 @@ curl https://nis2.agile.software/api/services/status</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="js/common.js?v=20260627"></script>
|
||||
<script src="js/common.js?v=20260628"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -112,8 +112,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -123,9 +123,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
@@ -494,8 +494,8 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della onboarding.html: ZERO modifiche backend -->
|
||||
<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>
|
||||
// ── Auth check ──────────────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -138,12 +138,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 src="/js/organigramma.js?v=20260617"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -333,8 +333,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -344,9 +344,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+5
-5
@@ -131,12 +131,12 @@
|
||||
</main>
|
||||
</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 src="/js/raci.js?v=20260619"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -268,8 +268,8 @@
|
||||
</script>
|
||||
|
||||
<!-- Stessa logica della register.html: ZERO modifiche backend -->
|
||||
<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>
|
||||
if (api.isAuthenticated()) { window.location.href = 'dashboard.html'; }
|
||||
|
||||
|
||||
+5
-5
@@ -443,8 +443,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -454,9 +454,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -141,12 +141,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';
|
||||
/*
|
||||
|
||||
+5
-5
@@ -494,8 +494,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -505,9 +505,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth & Init ──────────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -672,8 +672,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -683,9 +683,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -165,8 +165,8 @@
|
||||
</main>
|
||||
</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>
|
||||
// ── Auth check ───────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
@@ -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'), () => {});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Stakeholder - NIS2 Agile</title>
|
||||
<!-- 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="/css/style.css?v=20260627">
|
||||
<link rel="stylesheet" href="/css/style.css?v=20260628">
|
||||
<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-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';
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Portale stakeholder - NIS2 Agile</title>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-italia/dist/css/bootstrap-italia.min.css">
|
||||
<style>
|
||||
body { background:#f1f5f9; margin:0; font-family:'Titillium Web',system-ui,sans-serif; color:#1e293b; }
|
||||
.pw-wrap { max-width:720px; margin:0 auto; padding:24px 16px 60px; }
|
||||
.pw-head { display:flex; align-items:center; gap:12px; margin-bottom:18px; }
|
||||
.pw-head .logo { width:40px; height:40px; border-radius:9px; background:#0066CC; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:800; }
|
||||
.pw-head h1 { font-size:1.1rem; margin:0; }
|
||||
.pw-card { background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:22px 24px; box-shadow:0 6px 20px rgba(15,23,42,.05); margin-bottom:16px; }
|
||||
.pw-card h2 { font-size:1.15rem; margin:0 0 6px; }
|
||||
.pw-sub { color:#64748b; font-size:.88rem; margin-bottom:14px; }
|
||||
.pw-content { white-space:pre-wrap; background:#f8fafc; border:1px solid #e2e8f0; border-radius:10px; padding:14px 16px; font-size:.92rem; line-height:1.6; max-height:340px; overflow:auto; }
|
||||
.q-block { margin-bottom:18px; padding-bottom:16px; border-bottom:1px solid #f1f5f9; }
|
||||
.q-block:last-child { border-bottom:none; }
|
||||
.q-text { font-weight:600; font-size:.95rem; margin-bottom:8px; }
|
||||
.q-req { color:#dc2626; }
|
||||
.q-opt { display:flex; align-items:center; gap:8px; margin:5px 0; font-size:.92rem; }
|
||||
.q-block input[type=text], .q-block textarea, .q-block input[type=number] { width:100%; padding:9px 10px; border:1px solid #cbd5e1; border-radius:8px; font-size:.92rem; }
|
||||
.pw-field { margin-bottom:14px; }
|
||||
.pw-field label { display:block; font-weight:600; font-size:.88rem; margin-bottom:5px; }
|
||||
.pw-field input[type=text], .pw-field textarea { width:100%; padding:9px 10px; border:1px solid #cbd5e1; border-radius:8px; font-size:.92rem; }
|
||||
.btn-primary { background:#0066CC; border-color:#0066CC; }
|
||||
.pw-msg { padding:14px 16px; border-radius:10px; font-size:.92rem; margin-bottom:16px; }
|
||||
.pw-ok { background:#dcfce7; color:#166534; border:1px solid #bbf7d0; }
|
||||
.pw-err { background:#fee2e2; color:#991b1b; border:1px solid #fecaca; }
|
||||
.pw-foot { text-align:center; color:#94a3b8; font-size:.78rem; margin-top:24px; }
|
||||
.hidden { display:none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pw-wrap">
|
||||
<div class="pw-head"><div class="logo">N2</div><h1>NIS2 Agile — Portale stakeholder</h1></div>
|
||||
<div id="pw-app"><div class="pw-card"><div class="pw-sub">Caricamento…</div></div></div>
|
||||
<div class="pw-foot">Strumento di supporto alla compliance NIS2. Le risposte sono trasmesse all'organizzazione che ti ha inviato il link.</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
var TOKEN = new URLSearchParams(location.search).get('t') || '';
|
||||
var DATA = null;
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||||
function app() { return el('pw-app'); }
|
||||
|
||||
async function apiGet(path) {
|
||||
var r = await fetch('/api' + path, { headers: { 'Accept': 'application/json' } });
|
||||
return r.json();
|
||||
}
|
||||
async function apiPost(path, body) {
|
||||
var r = await fetch('/api' + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function card(html) { return '<div class="pw-card">' + html + '</div>'; }
|
||||
function msg(text, ok) { return '<div class="pw-msg ' + (ok ? 'pw-ok' : 'pw-err') + '">' + esc(text) + '</div>'; }
|
||||
|
||||
document.addEventListener('DOMContentLoaded', load);
|
||||
|
||||
async function load() {
|
||||
if (!TOKEN) { app().innerHTML = card(msg('Link di accesso mancante.', false)); return; }
|
||||
try {
|
||||
var res = await apiGet('/stakeholder-portal/access?t=' + encodeURIComponent(TOKEN));
|
||||
if (!res.success) { app().innerHTML = card(msg(res.message || 'Link non valido.', false)); return; }
|
||||
DATA = res.data;
|
||||
render();
|
||||
} catch (e) { app().innerHTML = card(msg('Errore di connessione.', false)); }
|
||||
}
|
||||
|
||||
function render() {
|
||||
var a = DATA.activity, t = DATA.template;
|
||||
var head = '<h2>' + esc(a.title) + '</h2><div class="pw-sub">Gentile <strong>' + esc(DATA.stakeholder_name) + '</strong>'
|
||||
+ (a.description ? ' — ' + esc(a.description) : '') + '</div>';
|
||||
|
||||
if (DATA.submitted) {
|
||||
app().innerHTML = card(head + msg(a.type === 'read_ack' ? 'Hai già confermato la lettura. Grazie.' : 'Hai già inviato le tue risposte. Grazie.', true))
|
||||
+ commentCard();
|
||||
return;
|
||||
}
|
||||
|
||||
var bodyHtml;
|
||||
if (a.type === 'read_ack') {
|
||||
bodyHtml = '<div class="pw-content">' + esc(t && t.content ? t.content : 'Documento da leggere e confermare.') + '</div>'
|
||||
+ '<div class="pw-field" style="margin-top:14px;"><label>Nome e cognome (firma)</label><input type="text" id="pw-name" maxlength="255" placeholder="Il tuo nome"></div>'
|
||||
+ '<button class="btn btn-primary" onclick="doAck()">Confermo di aver letto</button>';
|
||||
} else {
|
||||
var qs = (t && t.questions) ? t.questions : [];
|
||||
bodyHtml = qs.length ? qs.map(qHtml).join('') : '<div class="pw-sub">Nessuna domanda configurata.</div>';
|
||||
bodyHtml += '<div class="pw-field" style="margin-top:6px;"><label>Nome e cognome (facoltativo)</label><input type="text" id="pw-name" maxlength="255"></div>'
|
||||
+ '<button class="btn btn-primary" onclick="doRespond()">Invia risposte</button>';
|
||||
}
|
||||
app().innerHTML = '<div id="pw-flash"></div>' + card(head + bodyHtml) + commentCard();
|
||||
}
|
||||
|
||||
function qHtml(q, i) {
|
||||
var code = esc(q.code || ('Q' + (i + 1)));
|
||||
var req = q.required ? ' <span class="q-req">*</span>' : '';
|
||||
var inner = '';
|
||||
if (q.type === 'yes_no') {
|
||||
inner = ['Sì', 'No'].map(function (o) { return '<label class="q-opt"><input type="radio" name="' + code + '" value="' + o + '"> ' + o + '</label>'; }).join('');
|
||||
} else if (q.type === 'scale_1_5') {
|
||||
inner = [1,2,3,4,5].map(function (n) { return '<label class="q-opt" style="display:inline-flex;margin-right:14px;"><input type="radio" name="' + code + '" value="' + n + '"> ' + n + '</label>'; }).join('');
|
||||
} else if (q.type === 'single_choice' && q.options) {
|
||||
inner = q.options.map(function (o) { return '<label class="q-opt"><input type="radio" name="' + code + '" value="' + esc(o) + '"> ' + esc(o) + '</label>'; }).join('');
|
||||
} else if (q.type === 'multi_choice' && q.options) {
|
||||
inner = q.options.map(function (o) { return '<label class="q-opt"><input type="checkbox" name="' + code + '" value="' + esc(o) + '"> ' + esc(o) + '</label>'; }).join('');
|
||||
} else if (q.type === 'number') {
|
||||
inner = '<input type="number" name="' + code + '">';
|
||||
} else {
|
||||
inner = '<textarea name="' + code + '" rows="2"></textarea>';
|
||||
}
|
||||
return '<div class="q-block" data-code="' + code + '" data-type="' + esc(q.type || 'text') + '"><div class="q-text">' + esc(q.text) + req + '</div>' + inner + '</div>';
|
||||
}
|
||||
|
||||
function collectAnswers() {
|
||||
var out = {};
|
||||
document.querySelectorAll('.q-block').forEach(function (b) {
|
||||
var code = b.getAttribute('data-code'), type = b.getAttribute('data-type');
|
||||
if (type === 'multi_choice') {
|
||||
out[code] = Array.prototype.filter.call(b.querySelectorAll('input[type=checkbox]'), function (c) { return c.checked; }).map(function (c) { return c.value; });
|
||||
} else if (type === 'yes_no' || type === 'single_choice' || type === 'scale_1_5') {
|
||||
var sel = b.querySelector('input:checked'); out[code] = sel ? sel.value : '';
|
||||
} else {
|
||||
var f = b.querySelector('textarea,input'); out[code] = f ? f.value.trim() : '';
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function flash(text, ok) { var f = el('pw-flash'); if (f) f.innerHTML = msg(text, ok); }
|
||||
|
||||
async function doRespond() {
|
||||
var answers = collectAnswers();
|
||||
var res = await apiPost('/stakeholder-portal/respond', { t: TOKEN, answers: answers, respondent_name: (el('pw-name') || {}).value });
|
||||
if (!res.success) { flash(res.message || 'Errore', false); return; }
|
||||
DATA.submitted = true; render(); window.scrollTo(0, 0);
|
||||
}
|
||||
async function doAck() {
|
||||
var res = await apiPost('/stakeholder-portal/acknowledge', { t: TOKEN, respondent_name: (el('pw-name') || {}).value });
|
||||
if (!res.success) { flash(res.message || 'Errore', false); return; }
|
||||
DATA.submitted = true; render(); window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function commentCard() {
|
||||
return card('<h2 style="font-size:1rem;">Un commento? (facoltativo)</h2>'
|
||||
+ '<div class="pw-field"><textarea id="pw-comment" rows="2" placeholder="Scrivi una nota per l\'organizzazione"></textarea></div>'
|
||||
+ '<div id="pw-comment-flash"></div>'
|
||||
+ '<button class="btn btn-outline-primary btn-sm" onclick="doComment()">Invia commento</button>');
|
||||
}
|
||||
async function doComment() {
|
||||
var body = (el('pw-comment') || {}).value || '';
|
||||
if (!body.trim()) return;
|
||||
var res = await apiPost('/stakeholder-portal/comment', { t: TOKEN, body: body });
|
||||
var f = el('pw-comment-flash');
|
||||
if (f) f.innerHTML = msg(res.success ? 'Commento inviato. Grazie.' : (res.message || 'Errore'), res.success);
|
||||
if (res.success && el('pw-comment')) el('pw-comment').value = '';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -477,8 +477,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -488,9 +488,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
* Il nome cache include la release: va BUMPATO ad ogni rilascio (vedi version.json),
|
||||
* cosi' activate elimina automaticamente la shell vecchia.
|
||||
*/
|
||||
const CACHE = 'nis2-shell-v1.20.0';
|
||||
const CACHE = 'nis2-shell-v1.21.0';
|
||||
|
||||
const PRECACHE = [
|
||||
'/offline.html',
|
||||
|
||||
@@ -292,8 +292,8 @@
|
||||
</main>
|
||||
</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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -303,9 +303,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
// ── Auth & Init ─────────────────────────────────────────
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version": "1.20.0", "build": "2026-06-16-v1.20.0", "date": "2026-06-16", "changelog": "Epic C / C5.2a - Attività stakeholder (fondazione): questionari tipo (template) con domande + m2m a procedure e misure/requisiti; attività assegnabili per codice stakeholder o a singoli; pianificazione data/scadenza sul calendario NIS2 (review_schedule esteso); invio con magic-link per destinatario (email disattivate -> condivisione manuale). mig.052: stk_questionnaire_templates, stk_template_(procedures|misure|requisiti), stk_activities, stk_activity_targets, stk_activity_procedures. StakeholderActivityController + endpoint /api/stakeholder-activities/*; pagina stakeholder-activities.html; voce sidebar V2. Additivo. (C5.2b: portale esterno + sotto-dashboard feedback.)"}
|
||||
{"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."}
|
||||
|
||||
@@ -207,8 +207,8 @@
|
||||
|
||||
<!-- Report Detail Modal handled by common.js showModal -->
|
||||
|
||||
<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>
|
||||
<!-- Bootstrap Italia bundle (componenti) + override sidebar BI (common-bi.js).
|
||||
common-bi.js va DOPO common.js: ridefinisce solo loadSidebar() con markup BI,
|
||||
riusando tutti gli helper di common.js (zero backend). -->
|
||||
@@ -218,9 +218,9 @@
|
||||
bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts');
|
||||
}
|
||||
</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>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
@@ -319,9 +319,9 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js?v=20260627"></script>
|
||||
<script src="js/common.js?v=20260627"></script>
|
||||
<script src="js/i18n.js?v=20260627"></script>
|
||||
<script src="js/api.js?v=20260628"></script>
|
||||
<script src="js/common.js?v=20260628"></script>
|
||||
<script src="js/i18n.js?v=20260628"></script>
|
||||
<script>
|
||||
if (!checkAuth()) throw new Error('Not authenticated');
|
||||
loadSidebar();
|
||||
|
||||
Reference in New Issue
Block a user