nis2-agile/public/licenseExt.html

944 lines
48 KiB
HTML

<!doctype html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NIS2 Agile — Gestione Licenze</title>
<link rel="stylesheet" href="css/style.css">
<style>
/* ── Layout ── */
body { background: var(--bg-main); }
.lic-layout { display: flex; gap: 0; min-height: 100vh; }
/* ── Login overlay ── */
.login-overlay {
position: fixed; inset: 0;
background: rgba(10,12,20,.95);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.login-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 2.5rem;
width: 360px;
text-align: center;
}
.login-box .logo { font-size: 2rem; margin-bottom: .5rem; }
.login-box h2 { font-size: 1.1rem; color: var(--primary); margin-bottom: .25rem; }
.login-box p { font-size: .82rem; color: var(--text-secondary); margin-bottom: 1.75rem; }
.login-box .form-group { margin-bottom: 1rem; text-align: left; }
.login-box label { font-size: .75rem; color: var(--text-secondary); display: block; margin-bottom: .3rem; }
.login-box input {
width: 100%; padding: .6rem .75rem;
background: var(--bg-main); border: 1px solid var(--border);
border-radius: 8px; color: var(--text-primary); font-size: .9rem;
box-sizing: border-box;
}
.login-box input:focus { outline: none; border-color: var(--primary); }
.login-err { color: #ef4444; font-size: .8rem; margin-top: .5rem; min-height: 1.2rem; }
/* ── Sidebar ── */
.lic-sidebar {
width: 240px; min-width: 240px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
padding: 1.5rem 1rem;
display: flex; flex-direction: column;
}
.lic-sidebar .brand { font-size: 1rem; font-weight: 700; color: var(--primary); margin-bottom: 2rem; }
.lic-sidebar .brand span { color: var(--text-secondary); font-weight: 400; font-size: .75rem; display: block; }
.nav-item {
display: flex; align-items: center; gap: .6rem;
padding: .55rem .75rem; border-radius: 8px;
font-size: .85rem; color: var(--text-secondary);
cursor: pointer; margin-bottom: .15rem;
transition: background .15s, color .15s;
border: none; background: none; width: 100%; text-align: left;
}
.nav-item:hover { background: rgba(255,255,255,.05); color: var(--text-primary); }
.nav-item.active { background: rgba(6,182,212,.12); color: var(--primary); }
.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; }
.sidebar-sep { height: 1px; background: var(--border); margin: .75rem 0; }
.sidebar-user { margin-top: auto; font-size: .75rem; color: var(--text-secondary); padding: .5rem .75rem; }
.sidebar-user strong { color: var(--text-primary); display: block; }
/* ── Main ── */
.lic-main { flex: 1; padding: 2rem; overflow-y: auto; }
.page-header { margin-bottom: 1.75rem; }
.page-header h1 { font-size: 1.3rem; font-weight: 700; margin-bottom: .25rem; }
.page-header p { color: var(--text-secondary); font-size: .85rem; }
/* ── Stats strip ── */
.stats-strip {
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 1rem; margin-bottom: 2rem;
}
.stat-box {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 1rem 1.25rem;
}
.stat-box .num { font-size: 1.8rem; font-weight: 700; color: var(--primary); }
.stat-box .lbl { font-size: .75rem; color: var(--text-secondary); margin-top: .1rem; }
/* ── Generate form ── */
.gen-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 1.5rem; margin-bottom: 2rem;
}
.gen-card h2 { font-size: 1rem; font-weight: 700; margin-bottom: 1.25rem; }
.gen-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.gen-full { grid-column: 1 / -1; }
.form-group label { display: block; font-size: .75rem; color: var(--text-secondary); margin-bottom: .3rem; }
.form-group input, .form-group select, .form-group textarea {
width: 100%; padding: .55rem .75rem; box-sizing: border-box;
background: var(--bg-main); border: 1px solid var(--border);
border-radius: 8px; color: var(--text-primary); font-size: .85rem;
transition: border-color .15s;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none; border-color: var(--primary);
}
.form-group select option { background: #1e2235; }
.form-hint { font-size: .72rem; color: var(--text-secondary); margin-top: .25rem; }
.gen-actions { display: flex; gap: .75rem; align-items: center; margin-top: 1.25rem; }
.gen-result {
background: rgba(6,182,212,.08); border: 1px solid rgba(6,182,212,.25);
border-radius: 8px; padding: 1rem 1.25rem; margin-top: 1rem; display: none;
}
.gen-result.show { display: block; }
.gen-result .token-val {
font-family: monospace; font-size: .9rem; color: #4ade80;
word-break: break-all; background: rgba(0,0,0,.3);
padding: .4rem .6rem; border-radius: 5px; margin: .5rem 0;
}
.copy-btn {
background: none; border: 1px solid var(--border);
color: var(--text-secondary); border-radius: 6px;
padding: .25rem .6rem; font-size: .72rem; cursor: pointer;
transition: border-color .15s, color .15s;
}
.copy-btn:hover { border-color: var(--primary); color: var(--primary); }
/* ── Table ── */
.table-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
}
.table-toolbar {
display: flex; gap: .75rem; align-items: center;
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.table-toolbar h2 { font-size: .95rem; font-weight: 700; flex: 1; margin: 0; }
.filter-select {
padding: .4rem .6rem; background: var(--bg-main);
border: 1px solid var(--border); border-radius: 7px;
color: var(--text-secondary); font-size: .78rem; cursor: pointer;
}
.lic-table { width: 100%; border-collapse: collapse; font-size: .82rem; }
.lic-table th {
background: rgba(255,255,255,.04); color: var(--text-secondary);
font-weight: 600; text-align: left; padding: .65rem .9rem;
border-bottom: 1px solid var(--border);
}
.lic-table td { padding: .65rem .9rem; border-bottom: 1px solid rgba(255,255,255,.04); }
.lic-table tr:last-child td { border-bottom: none; }
.lic-table tr:hover td { background: rgba(255,255,255,.02); }
.badge-status {
display: inline-block; font-size: .65rem; font-weight: 700;
padding: .2rem .5rem; border-radius: 4px; text-transform: uppercase;
}
.s-pending { background: rgba(6,182,212,.15); color: #06b6d4; }
.s-used { background: rgba(99,102,241,.15); color: #818cf8; }
.s-expired { background: rgba(239,68,68,.15); color: #ef4444; }
.s-revoked { background: rgba(156,163,175,.15); color: #9ca3af; }
.plan-badge {
display: inline-block; font-size: .65rem; font-weight: 700;
padding: .15rem .45rem; border-radius: 4px;
}
.p-essentials { background: rgba(34,197,94,.1); color: #22c55e; }
.p-professional { background: rgba(6,182,212,.1); color: #06b6d4; }
.p-enterprise { background: rgba(168,85,247,.1); color: #a855f7; }
.use-bar {
display: flex; align-items: center; gap: .4rem;
}
.bar-track {
width: 60px; height: 6px; background: rgba(255,255,255,.08); border-radius: 3px;
}
.bar-fill { height: 100%; border-radius: 3px; background: var(--primary); transition: width .3s; }
.bar-fill.full { background: #818cf8; }
.act-btn {
background: none; border: 1px solid var(--border);
color: var(--text-secondary); border-radius: 5px;
padding: .2rem .45rem; font-size: .72rem; cursor: pointer;
transition: all .15s; white-space: nowrap;
}
.act-btn:hover { border-color: var(--primary); color: var(--primary); }
.act-btn.danger:hover { border-color: #ef4444; color: #ef4444; }
.empty-row td { text-align: center; color: var(--text-secondary); padding: 2rem !important; }
/* ── Spinner ── */
.spin { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.2); border-top-color: var(--primary); border-radius: 50%; animation: spin .6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Toast ── */
#toast {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: .75rem 1.25rem;
font-size: .85rem; max-width: 320px;
box-shadow: 0 8px 32px rgba(0,0,0,.4);
transform: translateY(120%); transition: transform .3s;
}
#toast.show { transform: translateY(0); }
#toast.ok { border-color: #22c55e; }
#toast.err { border-color: #ef4444; }
/* ── Detail panel ── */
.detail-row { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.detail-item { flex: 1; min-width: 160px; }
.detail-item .dl { font-size: .72rem; color: var(--text-secondary); margin-bottom: .15rem; }
.detail-item .dv { font-size: .88rem; font-weight: 600; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.7);
display: flex; align-items: center; justify-content: center;
z-index: 500; padding: 1rem;
}
.modal-box {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 14px; padding: 1.5rem;
width: 100%; max-width: 540px; max-height: 90vh; overflow-y: auto;
}
.modal-box h3 { font-size: 1rem; margin-bottom: 1.25rem; }
.modal-close { float: right; background: none; border: none; color: var(--text-secondary); font-size: 1.2rem; cursor: pointer; margin-top: -.2rem; }
</style>
</head>
<body>
<!-- Login overlay -->
<div class="login-overlay" id="loginOverlay">
<div class="login-box">
<div class="logo">🔑</div>
<h2>Gestione Licenze NIS2</h2>
<p>Accesso riservato al team marketing e amministratori</p>
<div class="form-group">
<label>Email</label>
<input type="email" id="loginEmail" placeholder="email@agile.software">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="loginPwd" placeholder="••••••••" onkeydown="if(event.key==='Enter')doLogin()">
</div>
<button class="btn btn-primary" style="width:100%" onclick="doLogin()" id="loginBtn">Accedi</button>
<div class="login-err" id="loginErr"></div>
</div>
</div>
<!-- Main layout -->
<div class="lic-layout">
<!-- Sidebar -->
<aside class="lic-sidebar">
<div class="brand">
NIS2 Agile
<span>Gestione Licenze</span>
</div>
<button class="nav-item active" onclick="showSection('dashboard')">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
Dashboard
</button>
<button class="nav-item" onclick="showSection('generate')">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
Genera Licenza
</button>
<button class="nav-item" onclick="showSection('list')">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
Tutte le Licenze
</button>
<div class="sidebar-sep"></div>
<button class="nav-item" onclick="showSection('generate')" style="background:rgba(6,182,212,.12);color:var(--primary)">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
Nuova Licenza
</button>
<div class="sidebar-sep"></div>
<div class="sidebar-user">
<strong id="sidebarUser"></strong>
<a href="javascript:void(0)" onclick="doLogout()" style="color:var(--text-secondary);font-size:.72rem;text-decoration:none">Esci</a>
</div>
</aside>
<!-- Main content -->
<main class="lic-main">
<!-- ── SECTION: Dashboard ── -->
<section id="sec-dashboard">
<div class="page-header">
<h1>Dashboard Licenze</h1>
<p>Panoramica delle licenze generate e del loro utilizzo</p>
</div>
<div class="stats-strip" id="statsStrip">
<div class="stat-box"><div class="num" id="st-total"></div><div class="lbl">Licenze totali</div></div>
<div class="stat-box"><div class="num" id="st-pending"></div><div class="lbl">In attesa di uso</div></div>
<div class="stat-box"><div class="num" id="st-used"></div><div class="lbl">Usate / Attive</div></div>
<div class="stat-box"><div class="num" id="st-expired"></div><div class="lbl">Scadute</div></div>
<div class="stat-box"><div class="num" id="st-orgs"></div><div class="lbl">Aziende provisionate</div></div>
<div class="stat-box"><div class="num" id="st-users"></div><div class="lbl">Utenti coinvolti</div></div>
</div>
<!-- Recenti -->
<div class="table-card">
<div class="table-toolbar">
<h2>Ultime licenze generate</h2>
<button class="btn btn-sm" onclick="loadDashboard()">Aggiorna</button>
</div>
<div style="overflow-x:auto">
<table class="lic-table">
<thead><tr>
<th>Label</th><th>Piano</th><th>Canale</th><th>Uso</th>
<th>Scade</th><th>Stato</th><th>Azioni</th>
</tr></thead>
<tbody id="dashTableBody"><tr class="empty-row"><td colspan="7"><span class="spin"></span></td></tr></tbody>
</table>
</div>
</div>
</section>
<!-- ── SECTION: Genera ── -->
<section id="sec-generate" style="display:none">
<div class="page-header">
<h1>Genera Nuova Licenza</h1>
<p>Definisci le caratteristiche della licenza. Il prezzo e le politiche commerciali sono di competenza del marketing.</p>
</div>
<div class="gen-card">
<h2>Caratteristiche licenza</h2>
<div class="gen-grid">
<div class="form-group">
<label>Piano *</label>
<select id="gPlan">
<option value="essentials">Essentials — 3 utenti, funzioni base</option>
<option value="professional" selected>Professional — 10 utenti, AI + Webhook</option>
<option value="enterprise">Enterprise — utenti illimitati, API admin</option>
</select>
</div>
<div class="form-group">
<label>Durata licenza (mesi) *</label>
<select id="gDuration">
<option value="3">3 mesi</option>
<option value="6">6 mesi</option>
<option value="12" selected>12 mesi</option>
<option value="24">24 mesi</option>
<option value="36">36 mesi</option>
</select>
</div>
<div class="form-group">
<label>Max aziende (uses) *</label>
<input type="number" id="gMaxUses" value="1" min="1" max="100">
<div class="form-hint">1 = singola azienda · N = bundle reseller</div>
</div>
<div class="form-group">
<label>Max utenti per azienda</label>
<input type="number" id="gMaxUsers" placeholder="Illimitato" min="1" max="9999">
<div class="form-hint">Lascia vuoto = illimitato (segue piano)</div>
</div>
<div class="form-group">
<label>Validità invito (giorni)</label>
<input type="number" id="gExpDays" value="30" min="1" max="365">
<div class="form-hint">Giorni entro cui attivare l'invito</div>
</div>
<div class="form-group">
<label>Canale</label>
<select id="gChannel">
<option value="ecommerce">E-commerce</option>
<option value="lg231">lg231 Partner</option>
<option value="direct">Vendita diretta</option>
<option value="reseller">Reseller</option>
<option value="manual" selected>Manuale</option>
</select>
</div>
<div class="form-group">
<label>Label commerciale *</label>
<input type="text" id="gLabel" placeholder="Es: NIS2 Starter — Ordine 2026-042">
</div>
<div class="form-group">
<label>Destinatario interno (riferimento)</label>
<input type="text" id="gIssuedTo" placeholder="nome@azienda.it o ragione sociale">
<div class="form-hint">Solo riferimento interno — non visibile al cliente</div>
</div>
<div class="form-group">
<label>Nome reseller (riferimento interno)</label>
<input type="text" id="gReseller" placeholder="Es: lg231 Agile S.r.l.">
</div>
<div class="form-group">
<label>Prezzo applicato (€, solo riferimento)</label>
<input type="number" id="gPrice" placeholder="0.00" step="0.01" min="0">
<div class="form-hint">Non enforced da NIS2 — solo a fini statistici</div>
</div>
<div class="form-group">
<label>Quantità da generare</label>
<input type="number" id="gQty" value="1" min="1" max="50">
<div class="form-hint">Genera N token uguali (es: batch reseller)</div>
</div>
<div class="form-group gen-full">
<label>Note interne marketing</label>
<textarea id="gNotes" rows="2" placeholder="Ordine di riferimento, condizioni speciali, sconto applicato..."></textarea>
</div>
</div>
<!-- Dati destinatario — pre-compilano il form di registrazione -->
<h2 style="margin-top:1.5rem">Dati destinatario <span style="font-size:.78rem;font-weight:400;color:var(--text-secondary)">— pre-compilano il form di registrazione del cliente</span></h2>
<div class="gen-grid">
<div class="form-group">
<label>Nome *</label>
<input type="text" id="gRcpFirst" placeholder="Es: Cristiano">
</div>
<div class="form-group">
<label>Cognome *</label>
<input type="text" id="gRcpLast" placeholder="Es: Benassati">
</div>
<div class="form-group">
<label>Email destinatario *</label>
<input type="email" id="gRcpEmail" placeholder="presidenza@agile.software">
<div class="form-hint">Il cliente vedrà questa email pre-compilata nel form</div>
</div>
<div class="form-group">
<label>P.IVA azienda</label>
<input type="text" id="gRcpVat" placeholder="07776161213" maxlength="11">
<div class="form-hint">Pre-compila il campo P.IVA nel form di registrazione</div>
</div>
</div>
<div class="gen-grid" style="margin-top:1rem">
<div class="form-group">
<label>Limita a P.IVA (opzionale)</label>
<input type="text" id="gRestVat" placeholder="02345678901" maxlength="11">
<div class="form-hint">Solo questa P.IVA può usare l'invito</div>
</div>
<div class="form-group">
<label>Limita a email admin (opzionale)</label>
<input type="email" id="gRestEmail" placeholder="ciso@azienda.it">
</div>
</div>
<div class="gen-actions">
<button class="btn btn-primary" onclick="generateLicense()" id="genBtn">Genera Licenza</button>
<button class="btn btn-sm" onclick="resetForm()">Reset</button>
<span id="genLoading" style="display:none"><span class="spin"></span> Generazione...</span>
</div>
<!-- Result box -->
<div class="gen-result" id="genResult">
<div style="font-size:.8rem;color:var(--text-secondary);margin-bottom:.5rem">
⚠️ <strong>ATTENZIONE:</strong> salva il/i token subito — non saranno più visibili in chiaro.
</div>
<div id="genTokenList"></div>
<div style="margin-top:.75rem;display:flex;gap:.5rem;flex-wrap:wrap">
<button class="copy-btn" onclick="copyAllTokens()">Copia tutti i token</button>
<button class="copy-btn" onclick="copyInviteUrls()">Copia URL onboarding</button>
<button class="copy-btn" onclick="exportCsv()">Esporta CSV</button>
</div>
</div>
</div>
</section>
<!-- ── SECTION: Lista ── -->
<section id="sec-list" style="display:none">
<div class="page-header">
<h1>Tutte le Licenze</h1>
<p>Controllo completo di licenze generate, usate e scadute</p>
</div>
<div class="table-card">
<div class="table-toolbar">
<h2>Registro licenze</h2>
<select class="filter-select" id="filterStatus" onchange="loadList()">
<option value="">Tutti gli stati</option>
<option value="pending">In attesa</option>
<option value="used">Usate</option>
<option value="expired">Scadute</option>
<option value="revoked">Revocate</option>
</select>
<select class="filter-select" id="filterChannel" onchange="loadList()">
<option value="">Tutti i canali</option>
<option value="ecommerce">E-commerce</option>
<option value="lg231">lg231</option>
<option value="direct">Diretto</option>
<option value="reseller">Reseller</option>
<option value="manual">Manuale</option>
</select>
<button class="btn btn-sm" onclick="loadList()">Aggiorna</button>
<button class="btn btn-primary btn-sm" onclick="showSection('generate')">+ Nuova</button>
</div>
<div style="overflow-x:auto">
<table class="lic-table">
<thead><tr>
<th>ID</th><th>Label / Reseller</th><th>Piano</th><th>Canale</th>
<th>Aziende usate</th><th>Utenti/org</th><th>Durata</th>
<th>Scadenza invito</th><th>Stato</th><th>Azioni</th>
</tr></thead>
<tbody id="listTableBody"><tr class="empty-row"><td colspan="10"><span class="spin"></span></td></tr></tbody>
</table>
</div>
<div style="padding:.75rem 1.25rem;border-top:1px solid var(--border);font-size:.78rem;color:var(--text-secondary)" id="listPager"></div>
</div>
</section>
</main>
</div>
<!-- Detail modal -->
<div class="modal-overlay" id="detailModal" style="display:none" onclick="if(event.target===this)closeModal()">
<div class="modal-box">
<button class="modal-close" onclick="closeModal()"></button>
<h3 id="modalTitle">Dettaglio Licenza</h3>
<div id="modalBody"></div>
</div>
</div>
<div id="toast"></div>
<script src="js/api.js"></script>
<script>
// ══════════════════════════════════════════════════════
// CONFIG
// ══════════════════════════════════════════════════════
const API = 'https://nis2.agile.software/api';
let jwt = null;
let currentUser = null;
let lastGenerated = [];
let listOffset = 0;
// ══════════════════════════════════════════════════════
// AUTH
// ══════════════════════════════════════════════════════
async function doLogin() {
const email = document.getElementById('loginEmail').value.trim();
const pwd = document.getElementById('loginPwd').value;
const btn = document.getElementById('loginBtn');
const err = document.getElementById('loginErr');
err.textContent = '';
btn.disabled = true; btn.textContent = 'Accesso...';
try {
const r = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: pwd })
});
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.message || 'Credenziali non valide');
if (!['super_admin'].includes(d.data?.user?.role)) throw new Error('Accesso non autorizzato per questo ruolo');
jwt = d.data.access_token;
currentUser = d.data.user;
document.getElementById('loginOverlay').style.display = 'none';
document.getElementById('sidebarUser').textContent = currentUser.first_name + ' ' + currentUser.last_name;
loadDashboard();
} catch(e) {
err.textContent = e.message;
btn.disabled = false; btn.textContent = 'Accedi';
}
}
function doLogout() {
jwt = null; currentUser = null;
document.getElementById('loginOverlay').style.display = 'flex';
document.getElementById('loginPwd').value = '';
document.getElementById('loginErr').textContent = '';
document.getElementById('loginBtn').disabled = false;
document.getElementById('loginBtn').textContent = 'Accedi';
}
async function apiFetch(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (jwt) headers['Authorization'] = 'Bearer ' + jwt;
const r = await fetch(`${API}${path}`, { ...opts, headers });
const d = await r.json().catch(() => ({}));
if (r.status === 401) { doLogout(); throw new Error('Sessione scaduta'); }
return { ok: r.ok, status: r.status, data: d };
}
// ══════════════════════════════════════════════════════
// NAVIGATION
// ══════════════════════════════════════════════════════
function showSection(id) {
['dashboard','generate','list'].forEach(s => {
document.getElementById('sec-' + s).style.display = s === id ? '' : 'none';
});
document.querySelectorAll('.nav-item').forEach((b, i) => {
b.classList.toggle('active', i === ['dashboard','generate','list'].indexOf(id));
});
if (id === 'dashboard') loadDashboard();
if (id === 'list') { listOffset = 0; loadList(); }
}
// ══════════════════════════════════════════════════════
// DASHBOARD
// ══════════════════════════════════════════════════════
async function loadDashboard() {
const { ok, data } = await apiFetch('/invites/list?limit=10');
if (!ok) return;
const invites = data.data?.invites || [];
const total = data.data?.total || 0;
// Conta per stato
const counts = { pending: 0, used: 0, expired: 0, revoked: 0 };
invites.forEach(i => { if (counts[i.status] !== undefined) counts[i.status]++; });
// Conta orgs provisionate (usate)
const orgsProvisioned = invites.filter(i => i.used_by_org_id).length;
// Stima utenti (max_users_per_org * used)
const usersEst = invites.reduce((acc, i) => acc + (i.used_count > 0 ? (i.max_users_per_org || 10) : 0), 0);
document.getElementById('st-total').textContent = total;
document.getElementById('st-pending').textContent = counts.pending;
document.getElementById('st-used').textContent = counts.used;
document.getElementById('st-expired').textContent = counts.expired;
document.getElementById('st-orgs').textContent = orgsProvisioned;
document.getElementById('st-users').textContent = usersEst + (usersEst > 0 ? '+' : '');
renderTable('dashTableBody', invites, true);
}
// ══════════════════════════════════════════════════════
// LIST
// ══════════════════════════════════════════════════════
async function loadList() {
const status = document.getElementById('filterStatus')?.value || '';
const channel = document.getElementById('filterChannel')?.value || '';
let url = `/invites/list?limit=50&offset=${listOffset}`;
if (status) url += '&status=' + status;
if (channel) url += '&channel=' + channel;
document.getElementById('listTableBody').innerHTML = '<tr class="empty-row"><td colspan="10"><span class="spin"></span></td></tr>';
const { ok, data } = await apiFetch(url);
if (!ok) { showToast('Errore caricamento', 'err'); return; }
const invites = data.data?.invites || [];
const total = data.data?.total || 0;
renderTable('listTableBody', invites, false);
const pager = document.getElementById('listPager');
pager.textContent = `${invites.length} di ${total} licenze`;
}
// ══════════════════════════════════════════════════════
// RENDER TABLE
// ══════════════════════════════════════════════════════
function renderTable(tbodyId, invites, compact) {
const tb = document.getElementById(tbodyId);
if (!invites.length) {
tb.innerHTML = '<tr class="empty-row"><td colspan="10">Nessuna licenza trovata</td></tr>';
return;
}
tb.innerHTML = invites.map(inv => {
const pct = inv.max_uses > 0 ? Math.round((inv.used_count / inv.max_uses) * 100) : 0;
const full = inv.used_count >= inv.max_uses;
const exp = new Date(inv.expires_at) < new Date();
const expStr = new Date(inv.expires_at).toLocaleDateString('it-IT', { day:'2-digit', month:'short', year:'2-digit' });
const statusBadge = `<span class="badge-status s-${inv.status}">${statusLabel(inv.status)}</span>`;
const planBadge = `<span class="plan-badge p-${inv.plan}">${inv.plan}</span>`;
const useBar = `<div class="use-bar">
<div class="bar-track"><div class="bar-fill ${full?'full':''}" style="width:${pct}%"></div></div>
<span style="font-size:.75rem;color:var(--text-secondary)">${inv.used_count}/${inv.max_uses}</span>
</div>`;
const usersCell = inv.max_users_per_org ? inv.max_users_per_org : '<span style="color:var(--text-secondary)">∞</span>';
const durationCell = inv.duration_months + ' mesi';
const labelCell = `<div style="font-weight:500;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${inv.label||''}">${inv.label || '<em style="color:var(--text-secondary)">—</em>'}</div>
${inv.reseller_name ? `<div style="font-size:.72rem;color:var(--text-secondary)">${inv.reseller_name}</div>` : ''}`;
const actions = `
<button class="act-btn" onclick="showDetail(${inv.id})">Dettaglio</button>
${inv.status === 'pending' ? `<button class="act-btn danger" onclick="revokeInvite(${inv.id})">Revoca</button>` : ''}
${inv.status !== 'used' ? `<button class="act-btn" onclick="regenerateInvite(${inv.id})">Rigenera</button>` : ''}`;
if (compact) {
return `<tr>
<td>${labelCell}</td>
<td>${planBadge}</td>
<td><span style="font-size:.78rem;color:var(--text-secondary)">${inv.channel||'—'}</span></td>
<td>${useBar}</td>
<td><span style="font-size:.78rem;${exp?'color:#ef4444':''}">${expStr}</span></td>
<td>${statusBadge}</td>
<td style="white-space:nowrap">${actions}</td>
</tr>`;
} else {
return `<tr>
<td style="color:var(--text-secondary);font-size:.78rem">#${inv.id}</td>
<td>${labelCell}</td>
<td>${planBadge}</td>
<td><span style="font-size:.78rem;color:var(--text-secondary)">${inv.channel||'—'}</span></td>
<td>${useBar}</td>
<td>${usersCell}</td>
<td>${durationCell}</td>
<td><span style="font-size:.78rem;${exp?'color:#ef4444':''}">${expStr}</span></td>
<td>${statusBadge}</td>
<td style="white-space:nowrap">${actions}</td>
</tr>`;
}
}).join('');
}
function statusLabel(s) {
return { pending: 'Attiva', used: 'Usata', expired: 'Scaduta', revoked: 'Revocata' }[s] || s;
}
// ══════════════════════════════════════════════════════
// GENERATE
// ══════════════════════════════════════════════════════
async function generateLicense() {
const label = document.getElementById('gLabel').value.trim();
if (!label) { showToast('Label commerciale obbligatoria', 'err'); document.getElementById('gLabel').focus(); return; }
const body = {
plan: document.getElementById('gPlan').value,
duration_months: parseInt(document.getElementById('gDuration').value),
max_uses: parseInt(document.getElementById('gMaxUses').value) || 1,
invite_expires_days: parseInt(document.getElementById('gExpDays').value) || 30,
label,
channel: document.getElementById('gChannel').value,
issued_to: document.getElementById('gIssuedTo').value.trim() || undefined,
reseller_name: document.getElementById('gReseller').value.trim() || undefined,
notes: document.getElementById('gNotes').value.trim() || undefined,
quantity: parseInt(document.getElementById('gQty').value) || 1,
};
const maxUsers = document.getElementById('gMaxUsers').value;
if (maxUsers) body.max_users_per_org = parseInt(maxUsers);
const price = document.getElementById('gPrice').value;
if (price) body.price_eur = parseFloat(price);
const vat = document.getElementById('gRestVat').value.trim();
if (vat) body.restrict_vat = vat;
const email = document.getElementById('gRestEmail').value.trim();
if (email) body.restrict_email = email;
// Dati destinatario — pre-compilano il form di registrazione
const rcpFirst = document.getElementById('gRcpFirst').value.trim();
const rcpLast = document.getElementById('gRcpLast').value.trim();
const rcpEmail = document.getElementById('gRcpEmail').value.trim();
const rcpVat = document.getElementById('gRcpVat').value.trim();
if (rcpFirst) body.recipient_first_name = rcpFirst;
if (rcpLast) body.recipient_last_name = rcpLast;
if (rcpEmail) body.recipient_email = rcpEmail;
if (rcpVat) body.recipient_vat = rcpVat;
const btn = document.getElementById('genBtn');
const ldr = document.getElementById('genLoading');
btn.style.display = 'none'; ldr.style.display = '';
const { ok, data } = await apiFetch('/invites/create', {
method: 'POST',
body: JSON.stringify(body)
});
btn.style.display = ''; ldr.style.display = 'none';
if (!ok) { showToast(data?.message || 'Errore generazione', 'err'); return; }
lastGenerated = data.data?.invites || [];
showToast(`${lastGenerated.length} licenza/e generate`, 'ok');
renderGenResult(lastGenerated);
}
function renderGenResult(invites) {
const res = document.getElementById('genResult');
const list = document.getElementById('genTokenList');
list.innerHTML = invites.map((inv, i) => {
const r = inv.recipient;
const recipientLine = r ? `<div style="font-size:.72rem;color:#22c55e;margin-top:.2rem">
👤 ${[r.first_name, r.last_name].filter(Boolean).join(' ')}${r.email ? ` &lt;${r.email}&gt;` : ''}${r.vat ? ` · P.IVA ${r.vat}` : ''} — form pre-compilato ✓
</div>` : '';
return `
<div style="margin-bottom:.75rem;padding:.75rem;background:rgba(6,182,212,.05);border:1px solid rgba(6,182,212,.2);border-radius:8px">
<div style="font-size:.75rem;color:var(--text-secondary);margin-bottom:.3rem">
Licenza #${i+1}${inv.plan} · ${inv.duration_months} mesi · ID: ${inv.id}
· Scade invito: ${new Date(inv.expires_at).toLocaleDateString('it-IT')}
${inv.max_users_per_org ? ` · Max utenti/org: ${inv.max_users_per_org}` : ''}
${inv.price_eur ? ` · € ${inv.price_eur}` : ''}
</div>
<div class="token-val" id="tok-${i}">${inv.token}</div>
<div style="font-size:.72rem;color:var(--text-secondary);margin-top:.3rem">
🔗 Link da inviare al cliente: <a href="${inv.invite_url}" target="_blank" style="color:var(--primary)">${inv.invite_url}</a>
<button class="act-btn" style="font-size:.7rem;padding:.2rem .5rem;margin-left:.5rem" onclick="navigator.clipboard.writeText('${inv.invite_url}').then(()=>showToast('Link copiato','ok'))">Copia link</button>
</div>
${recipientLine}
</div>`;
}).join('');
res.classList.add('show');
res.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function copyAllTokens() {
const tokens = lastGenerated.map(i => i.token).join('\n');
navigator.clipboard.writeText(tokens).then(() => showToast('Token copiati', 'ok'));
}
function copyInviteUrls() {
const urls = lastGenerated.map(i => i.invite_url).join('\n');
navigator.clipboard.writeText(urls).then(() => showToast('URL copiati', 'ok'));
}
function exportCsv() {
const header = 'id,token,plan,duration_months,max_uses,max_users_per_org,price_eur,expires_at,invite_url';
const rows = lastGenerated.map(i =>
[i.id, i.token, i.plan, i.duration_months, i.max_uses, i.max_users_per_org || '', i.price_eur || '', i.expires_at, i.invite_url].join(',')
);
const csv = [header, ...rows].join('\n');
const a = document.createElement('a');
a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent('\uFEFF' + csv);
a.download = `licenze-nis2-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
}
function resetForm() {
['gLabel','gIssuedTo','gReseller','gNotes','gRestVat','gRestEmail','gPrice','gMaxUsers',
'gRcpFirst','gRcpLast','gRcpEmail','gRcpVat'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('gPlan').value = 'professional';
document.getElementById('gDuration').value = '12';
document.getElementById('gMaxUses').value = '1';
document.getElementById('gExpDays').value = '30';
document.getElementById('gChannel').value = 'manual';
document.getElementById('gQty').value = '1';
document.getElementById('genResult').classList.remove('show');
lastGenerated = [];
}
// ══════════════════════════════════════════════════════
// ACTIONS
// ══════════════════════════════════════════════════════
async function revokeInvite(id) {
if (!confirm(`Revocare la licenza #${id}? L'operazione non è reversibile.`)) return;
const { ok, data } = await apiFetch('/invites/' + id, { method: 'DELETE' });
if (!ok) { showToast(data?.message || 'Errore revoca', 'err'); return; }
showToast('Licenza revocata', 'ok');
closeModal();
loadDashboard();
}
async function regenerateInvite(id) {
if (!confirm(`Rigenerare il token per la licenza #${id}? Il vecchio token viene invalidato.`)) return;
const { ok, data } = await apiFetch('/invites/' + id + '/regenerate', { method: 'POST' });
if (!ok) { showToast(data?.message || 'Errore rigenerazione', 'err'); return; }
const newToken = data.data?.token;
showToast('Nuovo token generato', 'ok');
closeModal();
if (newToken) {
navigator.clipboard.writeText(newToken)
.then(() => showToast('Nuovo token: ' + newToken.substring(0,20) + '... (copiato)', 'ok'));
}
loadDashboard();
}
async function showDetail(id) {
const { ok, data } = await apiFetch('/invites/' + id);
if (!ok) { showToast('Errore caricamento', 'err'); return; }
const inv = data.data;
document.getElementById('modalTitle').textContent = `Licenza #${inv.id}${inv.label || 'senza label'}`;
document.getElementById('modalBody').innerHTML = `
<div class="detail-row" style="margin-bottom:1rem">
<div class="detail-item"><div class="dl">Piano</div><div class="dv"><span class="plan-badge p-${inv.plan}">${inv.plan}</span></div></div>
<div class="detail-item"><div class="dl">Stato</div><div class="dv"><span class="badge-status s-${inv.status}">${statusLabel(inv.status)}</span></div></div>
<div class="detail-item"><div class="dl">Canale</div><div class="dv">${inv.channel || '—'}</div></div>
</div>
<div class="detail-row" style="margin-bottom:1rem">
<div class="detail-item"><div class="dl">Aziende (uses)</div><div class="dv">${inv.used_count} / ${inv.max_uses}</div></div>
<div class="detail-item"><div class="dl">Max utenti/org</div><div class="dv">${inv.max_users_per_org ?? '∞ illimitati'}</div></div>
<div class="detail-item"><div class="dl">Durata licenza</div><div class="dv">${inv.duration_months} mesi</div></div>
</div>
<div class="detail-row" style="margin-bottom:1rem">
<div class="detail-item"><div class="dl">Scadenza invito</div><div class="dv">${new Date(inv.expires_at).toLocaleString('it-IT')}</div></div>
<div class="detail-item"><div class="dl">Prezzo (rif.)</div><div class="dv">${inv.price_eur ? '€ ' + inv.price_eur : '—'}</div></div>
<div class="detail-item"><div class="dl">Reseller</div><div class="dv">${inv.reseller_name || '—'}</div></div>
</div>
<div class="detail-row" style="margin-bottom:1rem">
<div class="detail-item"><div class="dl">Rif. interno</div><div class="dv">${inv.issued_to || '—'}</div></div>
<div class="detail-item"><div class="dl">Creato</div><div class="dv">${new Date(inv.created_at).toLocaleString('it-IT')}</div></div>
${inv.used_at ? `<div class="detail-item"><div class="dl">Usato il</div><div class="dv">${new Date(inv.used_at).toLocaleString('it-IT')}</div></div>` : ''}
</div>
${inv.restrict_vat || inv.restrict_email ? `
<div class="detail-row" style="margin-bottom:1rem">
${inv.restrict_vat ? `<div class="detail-item"><div class="dl">P.IVA limitata</div><div class="dv">${inv.restrict_vat}</div></div>` : ''}
${inv.restrict_email ? `<div class="detail-item"><div class="dl">Email limitata</div><div class="dv">${inv.restrict_email}</div></div>` : ''}
</div>` : ''}
${inv.used_by_org ? `
<div style="background:rgba(6,182,212,.07);border:1px solid rgba(6,182,212,.2);border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem">
<div style="font-size:.75rem;color:var(--text-secondary);margin-bottom:.4rem">Azienda che ha usato la licenza</div>
<strong>${inv.used_by_org.name}</strong>
<span style="font-size:.78rem;color:var(--text-secondary);margin-left:.5rem">${inv.used_by_org.sector || ''} · ${inv.used_by_org.nis2_entity_type || ''}</span>
</div>` : ''}
${inv.metadata_recipient ? (() => { const r = inv.metadata_recipient; return `
<div style="background:rgba(34,197,94,.07);border:1px solid rgba(34,197,94,.25);border-radius:8px;padding:.75rem 1rem;margin-bottom:1rem">
<div style="font-size:.75rem;color:var(--text-secondary);margin-bottom:.4rem">👤 Dati destinatario (pre-compilano il form)</div>
<div style="font-size:.88rem">
${[r.first_name, r.last_name].filter(Boolean).join(' ')}
${r.email ? `<span style="color:var(--primary);margin-left:.5rem">${r.email}</span>` : ''}
${r.vat ? `<span style="color:var(--text-secondary);margin-left:.5rem">P.IVA: ${r.vat}</span>` : ''}
</div>
</div>`; })() : ''}
${inv.notes ? `<div style="font-size:.82rem;color:var(--text-secondary);border-top:1px solid var(--border);padding-top:.75rem"><strong>Note:</strong> ${inv.notes}</div>` : ''}
<div style="display:flex;gap:.5rem;margin-top:1rem;flex-wrap:wrap">
${inv.status === 'pending' ? `<button class="act-btn danger" onclick="revokeInvite(${inv.id})">Revoca licenza</button>` : ''}
${inv.status !== 'used' ? `<button class="act-btn" onclick="regenerateInvite(${inv.id})">Rigenera token</button>` : ''}
<button class="act-btn" onclick="closeModal()">Chiudi</button>
</div>
`;
document.getElementById('detailModal').style.display = 'flex';
}
function closeModal() {
document.getElementById('detailModal').style.display = 'none';
}
// ══════════════════════════════════════════════════════
// TOAST
// ══════════════════════════════════════════════════════
function showToast(msg, type = 'ok') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'show ' + type;
clearTimeout(t._t);
t._t = setTimeout(() => { t.classList.remove('show'); }, 3500);
}
// ══════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════
// Prova con token esistente in localStorage
const savedJwt = localStorage.getItem('nis2_token');
if (savedJwt) {
jwt = savedJwt;
// Verifica che sia ancora valido
fetch(`${API}/auth/me`, { headers: { Authorization: 'Bearer ' + jwt } })
.then(r => r.json())
.then(d => {
if (d.success && d.data?.role === 'super_admin') {
currentUser = d.data;
document.getElementById('loginOverlay').style.display = 'none';
document.getElementById('sidebarUser').textContent = currentUser.first_name + ' ' + currentUser.last_name;
loadDashboard();
} else {
jwt = null;
localStorage.removeItem('nis2_token');
}
})
.catch(() => { jwt = null; });
}
document.getElementById('loginEmail').focus();
</script>
</body>
</html>