diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..97cbe46
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,26 @@
+# Git
+.git
+.gitignore
+
+# Environment & credentials
+.env
+docs/credentials/
+
+# IDE
+.vscode/
+.idea/
+
+# OS files
+Thumbs.db
+.DS_Store
+
+# Docker (avoid recursive copy)
+docker/
+
+# Documentation (not needed in image)
+CLAUDE.md
+*.md
+
+# Temp files
+*.log
+*.tmp
diff --git a/CLAUDE.md b/CLAUDE.md
index b2e26f3..4d33594 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,9 +2,9 @@
## PRIMA DI INIZIARE
- Leggi sempre questo file prima di iniziare qualsiasi lavoro
-- Il progetto e' al **97% di completamento** (23.500+ righe di codice, 48 file sorgente)
-- 5 commit su main, tutto deployato su Hetzner
-- Manca: test end-to-end, Docker setup, bug fixing, polish UI
+- Il progetto e' al **100% di completamento** (24.000+ righe di codice, 50+ file sorgente)
+- 7 commit su main, tutto deployato e testato su Hetzner
+- E2E test completati, bug fixing, Docker verificato, UI polished
## Panoramica
NIS2 Agile e' una piattaforma SaaS multi-tenant per supportare le aziende nella compliance alla Direttiva NIS2 (EU 2022/2555) e al D.Lgs. 138/2024 italiano. Include AI integration (Claude API) per gap analysis, generazione policy, classificazione incidenti e suggerimenti rischi.
@@ -162,11 +162,12 @@ Schema: `docs/sql/001_initial_schema.sql` + `docs/sql/002_email_log.sql`
## Git
- **Repository**: https://git.certisource.it/AdminGit2026/nis2-agile
- **Token Gitea**: bcaec92cad4071c8b2f938d6201a6a392c09f626
-- **Branch**: main (5 commit)
+- **Branch**: main (7 commit)
- **Commit format**: `[AREA] Descrizione`
### Cronologia Commit
```
+bcc5a2b [FIX] E2E testing - fix router, EmailService, frontend data mapping
6f4b457 [FEAT] Add EmailService, RateLimitService, ReportService + integrations
9aa2788 [FEAT] Add onboarding wizard with visura camerale and CertiSource integration
73e78ea [FEAT] Add all frontend pages - complete UI for NIS2 platform
@@ -192,10 +193,17 @@ Base: `/nis2/api/{controller}/{action}/{id?}`
### Onboarding: POST upload-visura, fetch-company, complete
### Admin: GET organizations, users, stats
-## Cosa Manca (3%)
-1. **Test end-to-end**: Registrare utente, onboarding, assessment, rischi, incidenti
-2. **Bug fixing**: Correggere errori che emergono dai test
-3. **Docker setup**: Verificare Dockerfile/docker-compose funzionanti
-4. **UI polish**: Miglioramenti responsive, animazioni, micro-interazioni
+## Stato Completamento
+Tutti i moduli sono implementati e testati:
+- [x] Test end-to-end: tutti gli endpoint API verificati
+- [x] Bug fixing: router rewrite, EmailService parse fix, frontend data mapping
+- [x] Docker setup: Dockerfile, docker-compose.yml, nginx.conf, php.ini verificati
+- [x] UI polish: animazioni, skeleton loaders, mobile backdrop, print styles, a11y
+
+### Bug Risolti (E2E Testing)
+1. **Router /{id}/subAction** - Pattern matching riscritto completamente per gestire GET /assessments/1/questions, GET /organizations/2/members, etc.
+2. **EmailService parse error** - PHP non supporta `??` dentro `{$var}` string interpolation, estratto a variabile
+3. **Frontend data mapping** - Dashboard, Assessment, Onboarding avevano nomi campo diversi dal backend
+4. **Field name mismatches** - annual_turnover→annual_turnover_eur, question_id→question_code, compliance_level→response_value
*Ultimo aggiornamento: 2026-02-17*
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 7a219a0..55d31a5 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,18 +1,20 @@
FROM php:8.4-fpm-alpine
-# Extensions
-RUN docker-php-ext-install pdo pdo_mysql
+# System dependencies
+RUN apk add --no-cache curl-dev
-# Curl extension
-RUN apk add --no-cache curl-dev && docker-php-ext-install curl
+# PHP extensions
+RUN docker-php-ext-install pdo pdo_mysql curl opcache
-# Config
+# Custom PHP config
COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini
WORKDIR /var/www/nis2-agile
COPY . .
-RUN chown -R www-data:www-data /var/www/nis2-agile
+# Create uploads directory and set permissions
+RUN mkdir -p /var/www/nis2-agile/public/uploads/visure \
+ && chown -R www-data:www-data /var/www/nis2-agile
EXPOSE 9000
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index ab45412..d66aca5 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -11,14 +11,17 @@ services:
volumes:
- ../application:/var/www/nis2-agile/application
- ../public:/var/www/nis2-agile/public
+ - nis2-uploads:/var/www/nis2-agile/public/uploads
+ env_file:
+ - ../.env
environment:
- APP_ENV=${APP_ENV:-production}
- APP_DEBUG=${APP_DEBUG:-false}
- DB_HOST=db
- DB_PORT=3306
- - DB_DATABASE=${DB_DATABASE:-nis2_agile_db}
- - DB_USERNAME=${DB_USERNAME:-nis2_user}
- - DB_PASSWORD=${DB_PASSWORD}
+ - DB_NAME=${DB_NAME:-nis2_agile_db}
+ - DB_USER=${DB_USER:-nis2_user}
+ - DB_PASS=${DB_PASS}
- JWT_SECRET=${JWT_SECRET}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
networks:
@@ -29,7 +32,7 @@ services:
# ── Nginx Web Server ─────────────────────────────────────────────────────
web:
- image: nginx:1.25-alpine
+ image: nginx:1.27-alpine
container_name: nis2-web
restart: unless-stopped
ports:
@@ -37,6 +40,7 @@ services:
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ../public:/var/www/nis2-agile/public:ro
+ - nis2-uploads:/var/www/nis2-agile/public/uploads:ro
networks:
- nis2-network
depends_on:
@@ -48,17 +52,18 @@ services:
container_name: nis2-db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
- MYSQL_DATABASE: ${DB_DATABASE:-nis2_agile_db}
- MYSQL_USER: ${DB_USERNAME:-nis2_user}
- MYSQL_PASSWORD: ${DB_PASSWORD}
+ MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpass}
+ MYSQL_DATABASE: ${DB_NAME:-nis2_agile_db}
+ MYSQL_USER: ${DB_USER:-nis2_user}
+ MYSQL_PASSWORD: ${DB_PASS}
ports:
- - "${DB_PORT:-3306}:3306"
+ - "${DB_EXPOSE_PORT:-3307}:3306"
volumes:
- nis2-db-data:/var/lib/mysql
- ../docs/sql/001_initial_schema.sql:/docker-entrypoint-initdb.d/001_initial_schema.sql:ro
+ - ../docs/sql/002_email_log.sql:/docker-entrypoint-initdb.d/002_email_log.sql:ro
healthcheck:
- test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-rootpass}"]
interval: 10s
timeout: 5s
retries: 5
@@ -70,6 +75,8 @@ services:
volumes:
nis2-db-data:
driver: local
+ nis2-uploads:
+ driver: local
# ── Networks ─────────────────────────────────────────────────────────────
networks:
diff --git a/docker/nginx.conf b/docker/nginx.conf
index 2b718f7..c8e1baf 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -32,6 +32,9 @@ server {
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
+ # Pass Authorization header (required for JWT auth)
+ fastcgi_param HTTP_AUTHORIZATION $http_authorization;
+
fastcgi_param HTTP_PROXY "";
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
diff --git a/docker/php.ini b/docker/php.ini
new file mode 100644
index 0000000..c128dae
--- /dev/null
+++ b/docker/php.ini
@@ -0,0 +1,30 @@
+[PHP]
+; ── Upload Limits ──────────────────────────────────────────────────────────
+upload_max_filesize = 20M
+post_max_size = 25M
+
+; ── Memory & Execution ─────────────────────────────────────────────────────
+memory_limit = 256M
+max_execution_time = 300
+max_input_time = 60
+
+; ── Error Handling ─────────────────────────────────────────────────────────
+display_errors = Off
+display_startup_errors = Off
+log_errors = On
+error_log = /var/log/php-errors.log
+error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
+
+; ── Timezone ───────────────────────────────────────────────────────────────
+date.timezone = Europe/Rome
+
+; ── Session ────────────────────────────────────────────────────────────────
+session.cookie_httponly = 1
+session.cookie_secure = 1
+session.use_strict_mode = 1
+
+; ── OPcache ────────────────────────────────────────────────────────────────
+opcache.enable = 1
+opcache.memory_consumption = 128
+opcache.max_accelerated_files = 4000
+opcache.validate_timestamps = 0
diff --git a/public/css/style.css b/public/css/style.css
index 1cacc53..ea034e0 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1639,3 +1639,331 @@ tbody tr:last-child td {
padding: 16px;
}
}
+
+/* ── Page Load Transition ────────────────────────────────────────── */
+.main-content {
+ animation: fadeInUp 0.35s ease both;
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* ── Skeleton Loaders ────────────────────────────────────────────── */
+.skeleton {
+ background: linear-gradient(90deg, var(--gray-200) 25%, var(--gray-100) 50%, var(--gray-200) 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+ border-radius: var(--border-radius-sm);
+}
+
+.skeleton-text {
+ height: 14px;
+ margin-bottom: 8px;
+ width: 80%;
+}
+
+.skeleton-text-sm {
+ height: 10px;
+ margin-bottom: 6px;
+ width: 60%;
+}
+
+.skeleton-title {
+ height: 20px;
+ margin-bottom: 12px;
+ width: 50%;
+}
+
+.skeleton-circle {
+ border-radius: 50%;
+}
+
+.skeleton-card {
+ padding: 24px;
+ background: var(--card-bg);
+ border-radius: var(--border-radius-lg);
+ box-shadow: var(--card-shadow);
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* ── Mobile Sidebar Backdrop ─────────────────────────────────────── */
+.sidebar-backdrop {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(15, 23, 42, 0.5);
+ backdrop-filter: blur(2px);
+ z-index: 99;
+ opacity: 0;
+ transition: opacity var(--transition);
+}
+
+.sidebar-backdrop.visible {
+ opacity: 1;
+}
+
+@media (max-width: 768px) {
+ .sidebar-backdrop.visible {
+ display: block;
+ }
+}
+
+/* ── Focus Visible (Keyboard Accessibility) ──────────────────────── */
+:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+
+.btn:focus-visible {
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.3);
+}
+
+.form-input:focus-visible,
+.form-select:focus-visible,
+.form-textarea:focus-visible {
+ outline: none;
+}
+
+.sidebar-nav a:focus-visible {
+ outline-offset: -2px;
+ outline-color: var(--primary-light);
+}
+
+/* ── Button Loading State ────────────────────────────────────────── */
+.btn.loading {
+ position: relative;
+ color: transparent !important;
+ pointer-events: none;
+}
+
+.btn.loading::after {
+ content: '';
+ position: absolute;
+ width: 18px;
+ height: 18px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+
+.btn-secondary.loading::after,
+.btn-ghost.loading::after {
+ border-color: rgba(0, 0, 0, 0.15);
+ border-top-color: var(--gray-600);
+}
+
+/* ── Staggered Card Entrance ─────────────────────────────────────── */
+.stats-grid > * {
+ animation: fadeInUp 0.4s ease both;
+}
+
+.stats-grid > *:nth-child(1) { animation-delay: 0.05s; }
+.stats-grid > *:nth-child(2) { animation-delay: 0.1s; }
+.stats-grid > *:nth-child(3) { animation-delay: 0.15s; }
+.stats-grid > *:nth-child(4) { animation-delay: 0.2s; }
+
+/* ── Custom Scrollbar (Sidebar) ──────────────────────────────────── */
+.sidebar::-webkit-scrollbar {
+ width: 4px;
+}
+
+.sidebar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.sidebar::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+}
+
+.sidebar::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+/* ── Tooltip ─────────────────────────────────────────────────────── */
+[data-tooltip] {
+ position: relative;
+}
+
+[data-tooltip]::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: calc(100% + 6px);
+ left: 50%;
+ transform: translateX(-50%) scale(0.95);
+ background: var(--gray-800);
+ color: #fff;
+ padding: 5px 10px;
+ border-radius: var(--border-radius-sm);
+ font-size: 0.7rem;
+ font-weight: 500;
+ white-space: nowrap;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity var(--transition-fast), transform var(--transition-fast);
+ z-index: 50;
+}
+
+[data-tooltip]:hover::after {
+ opacity: 1;
+ transform: translateX(-50%) scale(1);
+}
+
+/* ── Clickable Table Rows ────────────────────────────────────────── */
+tbody tr.clickable {
+ cursor: pointer;
+}
+
+tbody tr.clickable:hover {
+ background: var(--primary-bg);
+}
+
+tbody tr.clickable:active {
+ background: rgba(26, 115, 232, 0.12);
+}
+
+/* ── Alert Banners ───────────────────────────────────────────────── */
+.alert {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 18px;
+ border-radius: var(--border-radius);
+ font-size: 0.875rem;
+ margin-bottom: 16px;
+ border-left: 4px solid;
+}
+
+.alert-info {
+ background: var(--info-bg);
+ border-left-color: var(--info);
+ color: var(--gray-700);
+}
+
+.alert-warning {
+ background: var(--warning-bg);
+ border-left-color: var(--warning);
+ color: var(--gray-700);
+}
+
+.alert-danger {
+ background: var(--danger-bg);
+ border-left-color: var(--danger);
+ color: var(--gray-700);
+}
+
+.alert-success {
+ background: var(--secondary-bg);
+ border-left-color: var(--secondary);
+ color: var(--gray-700);
+}
+
+.alert svg {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+/* ── Tabs ────────────────────────────────────────────────────────── */
+.tabs {
+ display: flex;
+ border-bottom: 2px solid var(--gray-200);
+ margin-bottom: 24px;
+ overflow-x: auto;
+}
+
+.tab {
+ padding: 10px 20px;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--gray-500);
+ border-bottom: 2px solid transparent;
+ margin-bottom: -2px;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: all var(--transition-fast);
+ background: none;
+ border-top: none;
+ border-left: none;
+ border-right: none;
+ font-family: inherit;
+}
+
+.tab:hover {
+ color: var(--gray-700);
+}
+
+.tab.active {
+ color: var(--primary);
+ border-bottom-color: var(--primary);
+}
+
+/* ── Print Styles ────────────────────────────────────────────────── */
+@media print {
+ .sidebar,
+ .sidebar-toggle,
+ .sidebar-backdrop,
+ .content-header-actions,
+ .notification-container,
+ .modal-overlay,
+ .btn {
+ display: none !important;
+ }
+
+ .main-content {
+ margin-left: 0 !important;
+ animation: none !important;
+ }
+
+ .content-header {
+ position: static;
+ border-bottom: 2px solid #000;
+ padding: 16px 0;
+ }
+
+ .content-body {
+ padding: 16px 0;
+ }
+
+ .card {
+ box-shadow: none;
+ border: 1px solid #ddd;
+ break-inside: avoid;
+ }
+
+ .stats-grid > * {
+ animation: none !important;
+ }
+
+ body {
+ background: #fff;
+ color: #000;
+ font-size: 12pt;
+ }
+
+ a {
+ color: #000;
+ text-decoration: underline;
+ }
+
+ .badge {
+ border: 1px solid currentColor;
+ }
+}
diff --git a/public/js/common.js b/public/js/common.js
index 523033b..324e488 100644
--- a/public/js/common.js
+++ b/public/js/common.js
@@ -295,13 +295,35 @@ function _setupMobileToggle() {
if (!document.querySelector('.sidebar-toggle')) {
const toggle = document.createElement('button');
toggle.className = 'sidebar-toggle';
+ toggle.setAttribute('aria-label', 'Apri menu');
toggle.innerHTML = '';
- toggle.addEventListener('click', () => {
- const sidebar = document.getElementById('sidebar');
- if (sidebar) sidebar.classList.toggle('open');
- });
+ toggle.addEventListener('click', () => _toggleSidebar());
document.body.appendChild(toggle);
}
+
+ // Crea backdrop per mobile
+ if (!document.querySelector('.sidebar-backdrop')) {
+ const backdrop = document.createElement('div');
+ backdrop.className = 'sidebar-backdrop';
+ backdrop.addEventListener('click', () => _toggleSidebar(false));
+ document.body.appendChild(backdrop);
+ }
+}
+
+function _toggleSidebar(forceState) {
+ const sidebar = document.getElementById('sidebar');
+ const backdrop = document.querySelector('.sidebar-backdrop');
+ if (!sidebar) return;
+
+ const isOpen = typeof forceState === 'boolean' ? forceState : !sidebar.classList.contains('open');
+
+ if (isOpen) {
+ sidebar.classList.add('open');
+ if (backdrop) backdrop.classList.add('visible');
+ } else {
+ sidebar.classList.remove('open');
+ if (backdrop) backdrop.classList.remove('visible');
+ }
}
@@ -454,6 +476,27 @@ function iconCog() {
return '';
}
+/**
+ * Imposta lo stato di caricamento su un bottone.
+ * @param {HTMLElement|string} btn - Elemento o ID
+ * @param {boolean} loading - true per attivare, false per disattivare
+ * @param {string} [originalText] - Testo originale da ripristinare
+ */
+function setButtonLoading(btn, loading, originalText) {
+ if (typeof btn === 'string') btn = document.getElementById(btn);
+ if (!btn) return;
+ if (loading) {
+ btn._originalText = btn.textContent;
+ btn.classList.add('loading');
+ btn.disabled = true;
+ } else {
+ btn.classList.remove('loading');
+ btn.disabled = false;
+ if (originalText) btn.textContent = originalText;
+ else if (btn._originalText) btn.textContent = btn._originalText;
+ }
+}
+
function iconPlus() {
return '';
}