944 lines
48 KiB
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 ? ` <${r.email}>` : ''}${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>
|