[CORE] Initial project scaffold - NIS2 Agile Compliance Platform
Complete MVP implementation including: - PHP 8.4 backend with Front Controller pattern (80+ API endpoints) - Multi-tenant architecture with organization_id isolation - JWT authentication (HS256, 2h access + 7d refresh tokens) - 14 controllers: Auth, Organization, Assessment, Dashboard, Risk, Incident, Policy, SupplyChain, Training, Asset, Audit, Admin - AI Service integration (Anthropic Claude API) for gap analysis, risk suggestions, policy generation, incident classification - NIS2 gap analysis questionnaire (~80 questions, 10 categories) - MySQL schema (20 tables) with NIS2 Art. 21 compliance controls - NIS2 Art. 23 incident reporting workflow (24h/72h/30d) - Frontend: login, register, dashboard, assessment wizard, org setup - Docker configuration (PHP-FPM + Nginx + MySQL) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* NIS2 Agile - API Client
|
||||
*
|
||||
* Client JavaScript per comunicare con il backend REST API.
|
||||
*/
|
||||
|
||||
class NIS2API {
|
||||
constructor(baseUrl = '/nis2/api') {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = localStorage.getItem('nis2_access_token');
|
||||
this.refreshToken = localStorage.getItem('nis2_refresh_token');
|
||||
this.orgId = localStorage.getItem('nis2_org_id');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// HTTP Methods
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async request(method, endpoint, data = null, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
if (this.orgId) {
|
||||
headers['X-Organization-Id'] = this.orgId;
|
||||
}
|
||||
|
||||
const config = { method, headers };
|
||||
|
||||
if (data && (method === 'POST' || method === 'PUT')) {
|
||||
config.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
const json = await response.json();
|
||||
|
||||
// Token expired - try refresh
|
||||
if (response.status === 401 && this.refreshToken && !options._isRetry) {
|
||||
const refreshed = await this.doRefreshToken();
|
||||
if (refreshed) {
|
||||
return this.request(method, endpoint, data, { ...options, _isRetry: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!json.success && !options.silent) {
|
||||
console.error(`[API] ${method} ${endpoint}:`, json.message);
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (error) {
|
||||
console.error(`[API] Network error: ${method} ${endpoint}`, error);
|
||||
return { success: false, message: 'Errore di connessione al server' };
|
||||
}
|
||||
}
|
||||
|
||||
get(endpoint) { return this.request('GET', endpoint); }
|
||||
post(endpoint, data) { return this.request('POST', endpoint, data); }
|
||||
put(endpoint, data) { return this.request('PUT', endpoint, data); }
|
||||
del(endpoint) { return this.request('DELETE', endpoint); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Auth
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async login(email, password) {
|
||||
const result = await this.post('/auth/login', { email, password });
|
||||
if (result.success) {
|
||||
this.setTokens(result.data.access_token, result.data.refresh_token);
|
||||
if (result.data.organizations && result.data.organizations.length > 0) {
|
||||
const primary = result.data.organizations.find(o => o.is_primary) || result.data.organizations[0];
|
||||
this.setOrganization(primary.organization_id);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async register(email, password, fullName) {
|
||||
const result = await this.post('/auth/register', { email, password, full_name: fullName });
|
||||
if (result.success) {
|
||||
this.setTokens(result.data.access_token, result.data.refresh_token);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async doRefreshToken() {
|
||||
try {
|
||||
const result = await this.post('/auth/refresh', { refresh_token: this.refreshToken });
|
||||
if (result.success) {
|
||||
this.setTokens(result.data.access_token, result.data.refresh_token);
|
||||
return true;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
this.logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.post('/auth/logout', {}).catch(() => {});
|
||||
this.clearTokens();
|
||||
window.location.href = '/nis2/login.html';
|
||||
}
|
||||
|
||||
getMe() { return this.get('/auth/me'); }
|
||||
|
||||
setTokens(access, refresh) {
|
||||
this.token = access;
|
||||
this.refreshToken = refresh;
|
||||
localStorage.setItem('nis2_access_token', access);
|
||||
localStorage.setItem('nis2_refresh_token', refresh);
|
||||
}
|
||||
|
||||
clearTokens() {
|
||||
this.token = null;
|
||||
this.refreshToken = null;
|
||||
this.orgId = null;
|
||||
localStorage.removeItem('nis2_access_token');
|
||||
localStorage.removeItem('nis2_refresh_token');
|
||||
localStorage.removeItem('nis2_org_id');
|
||||
}
|
||||
|
||||
setOrganization(orgId) {
|
||||
this.orgId = orgId;
|
||||
localStorage.setItem('nis2_org_id', orgId);
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return !!this.token;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Organizations
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
createOrganization(data) { return this.post('/organizations/create', data); }
|
||||
getCurrentOrg() { return this.get('/organizations/current'); }
|
||||
listOrganizations() { return this.get('/organizations/list'); }
|
||||
updateOrganization(id, data) { return this.put(`/organizations/${id}`, data); }
|
||||
classifyEntity(data) { return this.post('/organizations/classify', data); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Assessments
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
listAssessments() { return this.get('/assessments/list'); }
|
||||
createAssessment(data) { return this.post('/assessments/create', data); }
|
||||
getAssessment(id) { return this.get(`/assessments/${id}`); }
|
||||
getAssessmentQuestions(id) { return this.get(`/assessments/${id}/questions`); }
|
||||
saveAssessmentResponse(id, data) { return this.post(`/assessments/${id}/respond`, data); }
|
||||
completeAssessment(id) { return this.post(`/assessments/${id}/complete`, {}); }
|
||||
getAssessmentReport(id) { return this.get(`/assessments/${id}/report`); }
|
||||
aiAnalyzeAssessment(id) { return this.post(`/assessments/${id}/ai-analyze`, {}); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Dashboard
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
getDashboardOverview() { return this.get('/dashboard/overview'); }
|
||||
getComplianceScore() { return this.get('/dashboard/compliance-score'); }
|
||||
getUpcomingDeadlines() { return this.get('/dashboard/upcoming-deadlines'); }
|
||||
getRecentActivity() { return this.get('/dashboard/recent-activity'); }
|
||||
getRiskHeatmap() { return this.get('/dashboard/risk-heatmap'); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Risks
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
listRisks(params = {}) { return this.get('/risks/list?' + new URLSearchParams(params)); }
|
||||
createRisk(data) { return this.post('/risks/create', data); }
|
||||
getRisk(id) { return this.get(`/risks/${id}`); }
|
||||
updateRisk(id, data) { return this.put(`/risks/${id}`, data); }
|
||||
deleteRisk(id) { return this.del(`/risks/${id}`); }
|
||||
getRiskMatrix() { return this.get('/risks/matrix'); }
|
||||
aiSuggestRisks() { return this.post('/risks/ai-suggest', {}); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Incidents
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
listIncidents(params = {}) { return this.get('/incidents/list?' + new URLSearchParams(params)); }
|
||||
createIncident(data) { return this.post('/incidents/create', data); }
|
||||
getIncident(id) { return this.get(`/incidents/${id}`); }
|
||||
updateIncident(id, data) { return this.put(`/incidents/${id}`, data); }
|
||||
sendEarlyWarning(id) { return this.post(`/incidents/${id}/early-warning`, {}); }
|
||||
sendNotification(id) { return this.post(`/incidents/${id}/notification`, {}); }
|
||||
sendFinalReport(id) { return this.post(`/incidents/${id}/final-report`, {}); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Policies
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
listPolicies(params = {}) { return this.get('/policies/list?' + new URLSearchParams(params)); }
|
||||
createPolicy(data) { return this.post('/policies/create', data); }
|
||||
getPolicy(id) { return this.get(`/policies/${id}`); }
|
||||
updatePolicy(id, data) { return this.put(`/policies/${id}`, data); }
|
||||
approvePolicy(id) { return this.post(`/policies/${id}/approve`, {}); }
|
||||
aiGeneratePolicy(category) { return this.post('/policies/ai-generate', { category }); }
|
||||
getPolicyTemplates() { return this.get('/policies/templates'); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Supply Chain
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
listSuppliers() { return this.get('/supply-chain/list'); }
|
||||
createSupplier(data) { return this.post('/supply-chain/create', data); }
|
||||
getSupplier(id) { return this.get(`/supply-chain/${id}`); }
|
||||
updateSupplier(id, data) { return this.put(`/supply-chain/${id}`, data); }
|
||||
assessSupplier(id, data) { return this.post(`/supply-chain/${id}/assess`, data); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Training
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
listCourses() { return this.get('/training/courses'); }
|
||||
getMyTraining() { return this.get('/training/assignments'); }
|
||||
getTrainingCompliance() { return this.get('/training/compliance-status'); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Assets
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
listAssets(params = {}) { return this.get('/assets/list?' + new URLSearchParams(params)); }
|
||||
createAsset(data) { return this.post('/assets/create', data); }
|
||||
getAsset(id) { return this.get(`/assets/${id}`); }
|
||||
updateAsset(id, data) { return this.put(`/assets/${id}`, data); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Audit
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
listControls() { return this.get('/audit/controls'); }
|
||||
updateControl(id, data) { return this.put(`/audit/controls/${id}`, data); }
|
||||
generateComplianceReport() { return this.get('/audit/report'); }
|
||||
getAuditLogs(params = {}) { return this.get('/audit/logs?' + new URLSearchParams(params)); }
|
||||
getIsoMapping() { return this.get('/audit/iso27001-mapping'); }
|
||||
}
|
||||
|
||||
// Singleton globale
|
||||
const api = new NIS2API();
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* NIS2 Agile - Common Utilities
|
||||
*
|
||||
* Funzioni condivise tra tutte le pagine del frontend.
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Notifications (Toast)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Mostra una notifica toast.
|
||||
* @param {string} message - Testo del messaggio
|
||||
* @param {string} type - 'success' | 'error' | 'warning' | 'info'
|
||||
* @param {number} duration - Durata in ms (default 4000)
|
||||
*/
|
||||
function showNotification(message, type = 'info', duration = 4000) {
|
||||
let container = document.querySelector('.notification-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'notification-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const icons = {
|
||||
success: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>',
|
||||
error: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>',
|
||||
warning: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>',
|
||||
info: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>'
|
||||
};
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification ${type}`;
|
||||
notification.innerHTML = `
|
||||
<span class="notification-icon">${icons[type] || icons.info}</span>
|
||||
<span class="notification-message">${escapeHtml(message)}</span>
|
||||
<button class="notification-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
container.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('fade-out');
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Modal
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Mostra un dialogo modale.
|
||||
* @param {string} title - Titolo del modale
|
||||
* @param {string} content - Contenuto HTML del body
|
||||
* @param {object} options - { footer: HTML, size: 'sm'|'md'|'lg' }
|
||||
*/
|
||||
function showModal(title, content, options = {}) {
|
||||
closeModal(); // Chiudi eventuale modale aperto
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'modal-overlay';
|
||||
|
||||
const sizeClass = options.size === 'lg' ? 'style="max-width:720px"' : options.size === 'sm' ? 'style="max-width:400px"' : '';
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" ${sizeClass}>
|
||||
<div class="modal-header">
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">${content}</div>
|
||||
${options.footer ? `<div class="modal-footer">${options.footer}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Chiudi cliccando fuori dal modale
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeModal();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('active');
|
||||
});
|
||||
|
||||
// Chiudi con ESC
|
||||
document.addEventListener('keydown', _modalEscHandler);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const overlay = document.getElementById('modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('active');
|
||||
setTimeout(() => overlay.remove(), 250);
|
||||
}
|
||||
document.removeEventListener('keydown', _modalEscHandler);
|
||||
}
|
||||
|
||||
function _modalEscHandler(e) {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Date Formatting
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Formatta una data in formato italiano (dd/mm/yyyy).
|
||||
* @param {string|Date} date
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDate(date) {
|
||||
if (!date) return '-';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleDateString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatta data e ora in formato italiano.
|
||||
* @param {string|Date} date
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDateTime(date) {
|
||||
if (!date) return '-';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleDateString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restituisce un'etichetta relativa (es. "2 ore fa", "ieri").
|
||||
* @param {string|Date} date
|
||||
* @returns {string}
|
||||
*/
|
||||
function timeAgo(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now - d;
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHrs = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMin < 1) return 'Adesso';
|
||||
if (diffMin < 60) return `${diffMin} min fa`;
|
||||
if (diffHrs < 24) return `${diffHrs} ore fa`;
|
||||
if (diffDays === 1) return 'Ieri';
|
||||
if (diffDays < 7) return `${diffDays} giorni fa`;
|
||||
return formatDate(date);
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Sidebar
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Inietta la sidebar di navigazione nell'elemento #sidebar.
|
||||
*/
|
||||
function loadSidebar() {
|
||||
const container = document.getElementById('sidebar');
|
||||
if (!container) return;
|
||||
|
||||
// Determina la pagina corrente
|
||||
const currentPage = window.location.pathname.split('/').pop() || 'dashboard.html';
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: 'Principale',
|
||||
items: [
|
||||
{ name: 'Dashboard', href: 'dashboard.html', icon: iconGrid() },
|
||||
{ name: 'Gap Analysis', href: 'assessment.html', icon: iconClipboardCheck() },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Gestione',
|
||||
items: [
|
||||
{ name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation() },
|
||||
{ name: 'Incidenti', href: 'incidents.html', icon: iconBell() },
|
||||
{ name: 'Policy', href: 'policies.html', icon: iconDocumentText() },
|
||||
{ name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink() },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Operativo',
|
||||
items: [
|
||||
{ name: 'Formazione', href: 'training.html', icon: iconAcademicCap() },
|
||||
{ name: 'Asset', href: 'assets.html', icon: iconServer() },
|
||||
{ name: 'Audit & Report', href: 'audit.html', icon: iconChartBar() },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Sistema',
|
||||
items: [
|
||||
{ name: 'Impostazioni', href: 'settings.html', icon: iconCog() },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
let navHTML = '';
|
||||
|
||||
// Brand
|
||||
navHTML += `
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-brand-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/><path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>NIS2 Agile</h1>
|
||||
<span>Compliance Platform</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Nav sections
|
||||
navHTML += '<nav class="sidebar-nav">';
|
||||
for (const section of navItems) {
|
||||
navHTML += `<div class="sidebar-nav-label">${section.label}</div>`;
|
||||
for (const item of section.items) {
|
||||
const isActive = currentPage === item.href ? 'active' : '';
|
||||
navHTML += `<a href="${item.href}" class="${isActive}">${item.icon}<span>${item.name}</span></a>`;
|
||||
}
|
||||
}
|
||||
navHTML += '</nav>';
|
||||
|
||||
// Footer with user info
|
||||
navHTML += `
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-avatar" id="sidebar-user-avatar">--</div>
|
||||
<div class="sidebar-user-info">
|
||||
<div class="sidebar-user-name" id="sidebar-user-name">Caricamento...</div>
|
||||
<div class="sidebar-user-role" id="sidebar-user-role">Utente</div>
|
||||
</div>
|
||||
<button class="sidebar-logout-btn" onclick="api.logout()" title="Esci">
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 001 1h6a1 1 0 100-2H4V5h5a1 1 0 100-2H3zm11.707 3.293a1 1 0 010 1.414L12.414 10l2.293 2.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M16 10a1 1 0 00-1-1H8a1 1 0 100 2h7a1 1 0 001-1z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = navHTML;
|
||||
|
||||
// Carica info utente
|
||||
_loadUserInfo();
|
||||
|
||||
// Mobile toggle
|
||||
_setupMobileToggle();
|
||||
}
|
||||
|
||||
async function _loadUserInfo() {
|
||||
try {
|
||||
const result = await api.getMe();
|
||||
if (result.success && result.data) {
|
||||
const user = result.data;
|
||||
const nameEl = document.getElementById('sidebar-user-name');
|
||||
const avatarEl = document.getElementById('sidebar-user-avatar');
|
||||
const roleEl = document.getElementById('sidebar-user-role');
|
||||
if (nameEl) nameEl.textContent = user.full_name || user.email;
|
||||
if (avatarEl) {
|
||||
const initials = (user.full_name || user.email || '--')
|
||||
.split(' ')
|
||||
.map(w => w[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
avatarEl.textContent = initials;
|
||||
}
|
||||
if (roleEl) roleEl.textContent = user.role === 'admin' ? 'Amministratore' : 'Utente';
|
||||
}
|
||||
} catch (e) {
|
||||
// Silenzioso
|
||||
}
|
||||
}
|
||||
|
||||
function _setupMobileToggle() {
|
||||
// Crea pulsante toggle se non esiste
|
||||
if (!document.querySelector('.sidebar-toggle')) {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'sidebar-toggle';
|
||||
toggle.innerHTML = '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
|
||||
toggle.addEventListener('click', () => {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar) sidebar.classList.toggle('open');
|
||||
});
|
||||
document.body.appendChild(toggle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Auth Check
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Verifica che l'utente sia autenticato. Se non lo e', redirige al login.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkAuth() {
|
||||
if (!api.isAuthenticated()) {
|
||||
window.location.href = 'login.html';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Utilities
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Escape HTML per prevenire XSS.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce: ritarda l'esecuzione di fn fino a delay ms dopo l'ultima chiamata.
|
||||
* @param {Function} fn
|
||||
* @param {number} delay
|
||||
* @returns {Function}
|
||||
*/
|
||||
function debounce(fn, delay = 300) {
|
||||
let timer;
|
||||
return function (...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restituisce la classe CSS per un punteggio di conformita'.
|
||||
* @param {number} score - Valore 0-100
|
||||
* @returns {string}
|
||||
*/
|
||||
function getScoreClass(score) {
|
||||
if (score >= 80) return 'score-excellent';
|
||||
if (score >= 60) return 'score-good';
|
||||
if (score >= 40) return 'score-medium';
|
||||
if (score >= 20) return 'score-low';
|
||||
return 'score-critical';
|
||||
}
|
||||
|
||||
/**
|
||||
* Restituisce il colore esadecimale per un punteggio.
|
||||
* @param {number} score - Valore 0-100
|
||||
* @returns {string}
|
||||
*/
|
||||
function getScoreColor(score) {
|
||||
if (score >= 80) return '#34a853';
|
||||
if (score >= 60) return '#84cc16';
|
||||
if (score >= 40) return '#fbbc04';
|
||||
if (score >= 20) return '#f97316';
|
||||
return '#ea4335';
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea l'SVG per il gauge circolare del punteggio.
|
||||
* @param {number} score - Valore 0-100
|
||||
* @param {number} size - Dimensione in px (default 180)
|
||||
* @returns {string} HTML
|
||||
*/
|
||||
function renderScoreGauge(score, size = 180) {
|
||||
const radius = (size / 2) - 14;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (score / 100) * circumference;
|
||||
const color = getScoreColor(score);
|
||||
const cls = getScoreClass(score);
|
||||
|
||||
return `
|
||||
<div class="score-gauge ${cls}" style="width:${size}px;height:${size}px">
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<circle class="score-gauge-circle-bg" cx="${size/2}" cy="${size/2}" r="${radius}"/>
|
||||
<circle class="score-gauge-circle" cx="${size/2}" cy="${size/2}" r="${radius}"
|
||||
stroke="${color}"
|
||||
stroke-dasharray="${circumference}"
|
||||
stroke-dashoffset="${offset}"
|
||||
style="--score-color: ${color}"/>
|
||||
</svg>
|
||||
<div class="score-gauge-value">
|
||||
<div class="score-gauge-number">${Math.round(score)}</div>
|
||||
<div class="score-gauge-label">Compliance</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SVG Icons (inline, nessuna dipendenza esterna)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function iconGrid() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 8a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zm6-6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zm0 8a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>';
|
||||
}
|
||||
|
||||
function iconClipboardCheck() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><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-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>';
|
||||
}
|
||||
|
||||
function iconShieldExclamation() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>';
|
||||
}
|
||||
|
||||
function iconBell() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/></svg>';
|
||||
}
|
||||
|
||||
function iconDocumentText() {
|
||||
return '<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>';
|
||||
}
|
||||
|
||||
function iconLink() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/></svg>';
|
||||
}
|
||||
|
||||
function iconAcademicCap() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0z"/></svg>';
|
||||
}
|
||||
|
||||
function iconServer() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>';
|
||||
}
|
||||
|
||||
function iconChartBar() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/></svg>';
|
||||
}
|
||||
|
||||
function iconCog() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>';
|
||||
}
|
||||
|
||||
function iconPlus() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>';
|
||||
}
|
||||
|
||||
function iconSparkles() {
|
||||
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>';
|
||||
}
|
||||
Reference in New Issue
Block a user