Session hardening: / and /admin now respond with Cache-Control: no-store, and
both pages re-derive their auth state on pageshow (event.persisted) as a
safety net against bfcache showing a stale logged-in/out view across
back/forward navigation. The user page also syncs logout across tabs via the
storage event, since localStorage is shared but in-memory JS state isn't.
Password recovery: admin gets a "Reset" button per user (POST
/admin/users/{id}/reset-password) that generates and sets a new password,
shown once — passwords are Argon2-hashed and can never be recovered, only
replaced. Users get self-service password change (POST
/users/me/change-password, requires the current password) under a new
Profilo tab, alongside read-only account info (username, address, balance,
join date).
Round display robustness: the user dashboard now refreshes immediately on
tab visibility change (background tabs get their timers throttled hard),
shows an explicit "connessione persa" state after repeated failed polls
instead of silently freezing on stale data, and polls faster both right when
the countdown hits zero and through the gap where the round is past its
deadline but still waiting for in-flight bets to confirm before the server
actually closes it.
716 lines
28 KiB
HTML
716 lines
28 KiB
HTML
<!doctype html>
|
|
<html lang="it">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>PLM Lottery — Admin</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600&family=Fira+Sans:wght@400;500;600;700&display=swap');
|
|
|
|
:root {
|
|
--color-background: #F8FAFC;
|
|
--color-surface: #FFFFFF;
|
|
--color-foreground: #0F172A;
|
|
--color-muted-foreground: #64748B;
|
|
--color-border: #E2E8F0;
|
|
--color-primary: #F59E0B;
|
|
--color-on-primary: #0F172A;
|
|
--color-destructive: #DC2626;
|
|
--color-destructive-bg: #FEF2F2;
|
|
--color-success: #16A34A;
|
|
--color-success-bg: #F0FDF4;
|
|
--color-ring: #F59E0B;
|
|
--radius: 12px;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: 'Fira Sans', system-ui, sans-serif;
|
|
background: var(--color-background);
|
|
color: var(--color-foreground);
|
|
margin: 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.mono { font-family: 'Fira Code', monospace; }
|
|
|
|
/* --- login screen: narrow, centered --- */
|
|
#login-section { max-width: 420px; margin: 15vh auto 0; padding: 0 20px; }
|
|
#login-section header { margin-bottom: 24px; }
|
|
#login-section header h1 { font-size: 1.375rem; font-weight: 700; margin: 0; letter-spacing: -0.01em; }
|
|
#login-section header p { color: var(--color-muted-foreground); font-size: 0.9rem; margin: 4px 0 0; }
|
|
|
|
/* --- dashboard: navbar + content --- */
|
|
.navbar {
|
|
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
|
|
background: var(--color-surface); border-bottom: 1px solid var(--color-border);
|
|
padding: 0 20px; position: sticky; top: 0; z-index: 10;
|
|
}
|
|
.navbar .brand { font-weight: 700; font-size: 1.05rem; padding: 14px 16px 14px 0; white-space: nowrap; }
|
|
.navbar .nav-tab {
|
|
padding: 16px 14px; font-size: 0.9rem; font-weight: 600; cursor: pointer;
|
|
color: var(--color-muted-foreground); border-bottom: 2px solid transparent;
|
|
margin-bottom: -1px; transition: color 150ms, border-color 150ms; white-space: nowrap;
|
|
}
|
|
.navbar .nav-tab.active { color: var(--color-foreground); border-bottom-color: var(--color-primary); }
|
|
.navbar .nav-tab:hover { color: var(--color-foreground); }
|
|
.navbar .spacer { flex: 1; }
|
|
|
|
.chain-status-pill { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; font-size: 0.82rem; white-space: nowrap; }
|
|
.status-dot {
|
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
|
background: var(--color-muted-foreground);
|
|
}
|
|
.status-dot.status-open {
|
|
background: var(--color-success);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 18%, transparent);
|
|
}
|
|
.status-dot.status-drawing {
|
|
background: var(--color-primary);
|
|
animation: status-dot-pulse 1400ms ease-in-out infinite;
|
|
}
|
|
.status-dot.status-waiting { background: var(--color-muted-foreground); }
|
|
@keyframes status-dot-pulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 45%, transparent); }
|
|
50% { box-shadow: 0 0 0 5px transparent; }
|
|
}
|
|
.chain-block { color: var(--color-muted-foreground); font-size: 0.82rem; white-space: nowrap; }
|
|
|
|
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
#maintenance-btn.btn-stop {
|
|
background: var(--color-destructive-bg); color: var(--color-destructive); border-color: var(--color-destructive);
|
|
}
|
|
.status-dot.status-paused { background: var(--color-destructive); }
|
|
|
|
main { max-width: 960px; margin: 0 auto; padding: 24px 20px 80px; }
|
|
|
|
.view { display: none; }
|
|
.view.active { display: block; }
|
|
|
|
h2.section-title { font-size: 1.15rem; font-weight: 700; margin: 0 0 4px; }
|
|
|
|
.card {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius);
|
|
padding: 20px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.card .hint { color: var(--color-muted-foreground); font-size: 0.85rem; margin: 0 0 14px; }
|
|
|
|
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0 20px; }
|
|
|
|
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 12px; margin-bottom: 6px; }
|
|
label:first-child { margin-top: 0; }
|
|
|
|
input {
|
|
width: 100%; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
|
|
border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface);
|
|
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
|
|
}
|
|
input:focus {
|
|
outline: none; border-color: var(--color-ring);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
|
|
}
|
|
|
|
button {
|
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
min-height: 44px; padding: 0 18px; margin-top: 16px; width: 100%;
|
|
font-family: inherit; font-size: 0.95rem; font-weight: 600;
|
|
background: var(--color-primary); color: var(--color-on-primary);
|
|
border: none; border-radius: 8px; cursor: pointer;
|
|
transition: filter 150ms, transform 150ms;
|
|
}
|
|
button:hover { filter: brightness(0.94); }
|
|
button:active { transform: scale(0.98); }
|
|
button:disabled { opacity: 0.6; cursor: default; }
|
|
button:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
|
|
|
|
button.secondary {
|
|
width: auto; margin-top: 0; min-height: 36px; padding: 0 14px;
|
|
background: var(--color-background); color: var(--color-foreground);
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
|
|
.hidden { display: none !important; }
|
|
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
|
|
th { color: var(--color-muted-foreground); font-weight: 500; }
|
|
td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; max-width: 200px; }
|
|
.table-wrap { overflow-x: auto; }
|
|
|
|
.badge {
|
|
display: inline-block; font-size: 0.72rem; font-weight: 600; padding: 2px 8px;
|
|
border-radius: 999px; background: var(--color-background); border: 1px solid var(--color-border);
|
|
}
|
|
.badge.status-open, .badge.status-confirmed, .badge.status-closed { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
|
.badge.status-pending, .badge.status-drawing, .badge.status-paying_out, .badge.status-closing, .badge.status-broadcast {
|
|
background: #FEF3C7; color: #92400E; border-color: #F59E0B;
|
|
}
|
|
|
|
button.reveal {
|
|
width: auto; margin-top: 0; padding: 4px 10px; min-height: 30px; font-size: 0.78rem;
|
|
background: var(--color-destructive-bg); color: var(--color-destructive); border: 1px solid var(--color-destructive);
|
|
}
|
|
|
|
.privkey-box {
|
|
margin-top: 6px; padding: 8px; border-radius: 6px; font-size: 0.78rem;
|
|
background: var(--color-destructive-bg); border: 1px solid var(--color-destructive);
|
|
word-break: break-all; font-family: 'Fira Code', monospace; color: var(--color-foreground);
|
|
}
|
|
.warning-banner {
|
|
background: var(--color-destructive-bg); border: 1px solid var(--color-destructive); color: var(--color-destructive);
|
|
border-radius: 8px; padding: 10px 12px; font-size: 0.8rem; margin-bottom: 14px; font-weight: 500;
|
|
}
|
|
|
|
pre.payload {
|
|
background: var(--color-background); border: 1px solid var(--color-border); border-radius: 6px;
|
|
padding: 6px 8px; font-size: 0.75rem; margin: 0; white-space: pre-wrap; word-break: break-all;
|
|
}
|
|
|
|
#toast-container {
|
|
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
|
display: flex; flex-direction: column; gap: 8px; z-index: 100; width: calc(100% - 40px); max-width: 440px;
|
|
}
|
|
.toast {
|
|
padding: 12px 14px; border-radius: 8px; font-size: 0.85rem; font-weight: 500;
|
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
|
|
animation: toast-in 200ms ease-out;
|
|
}
|
|
.toast.success { background: var(--color-success-bg); color: var(--color-success); }
|
|
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
|
|
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
* { animation: none !important; transition: none !important; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<section id="login-section">
|
|
<header>
|
|
<h1>PLM Lottery — Admin</h1>
|
|
<p>Accesso riservato</p>
|
|
</header>
|
|
|
|
<div class="card">
|
|
<label for="admin-token">Admin token</label>
|
|
<input id="admin-token" type="password" placeholder="valore di ADMIN_TOKEN" autofocus>
|
|
<button onclick="adminLogin()" id="login-btn">Accedi</button>
|
|
</div>
|
|
</section>
|
|
|
|
<div id="dashboard-section" class="hidden">
|
|
<nav class="navbar">
|
|
<span class="brand">PLM Lottery — Admin</span>
|
|
<span class="nav-tab active" id="nav-parametri" onclick="switchView('parametri')">Parametri</span>
|
|
<span class="nav-tab" id="nav-utenti" onclick="switchView('utenti')">Utenti</span>
|
|
<span class="nav-tab" id="nav-round" onclick="switchView('round')">Round</span>
|
|
<span class="nav-tab" id="nav-pending" onclick="switchView('pending')">Transazioni pendenti</span>
|
|
<span class="nav-tab" id="nav-audit" onclick="switchView('audit')">Audit log</span>
|
|
<span class="spacer"></span>
|
|
<span class="chain-status-pill">
|
|
<span class="status-dot" id="chain-status-dot"></span>
|
|
<span id="chain-status-label">Connessione…</span>
|
|
</span>
|
|
<span class="chain-block mono" id="chain-block">Blocco —</span>
|
|
<button class="secondary" style="margin:8px 0 8px 14px" onclick="adminLogout()">Esci</button>
|
|
</nav>
|
|
|
|
<main>
|
|
|
|
<div class="view active" id="view-parametri">
|
|
<h2 class="section-title">Parametri</h2>
|
|
<p class="hint">Configurazione operativa, salvata nel database — modificabile in qualsiasi momento senza riavviare il server.</p>
|
|
|
|
<div class="card" id="maintenance-card">
|
|
<h2>Manutenzione</h2>
|
|
<p class="hint" id="maintenance-hint">
|
|
Interrompe l'apertura di nuovi round dopo quello in corso, senza troncare il round attuale — chiusura,
|
|
estrazione e pagamento del vincitore avvengono normalmente. Gli utenti vedono un avviso di manutenzione.
|
|
</p>
|
|
<div class="row-between">
|
|
<span class="chain-status-pill">
|
|
<span class="status-dot" id="maintenance-dot"></span>
|
|
<span id="maintenance-status-label">—</span>
|
|
</span>
|
|
<button id="maintenance-btn" class="secondary" style="width:auto;margin-top:0" onclick="toggleMaintenance()">…</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="grid-2">
|
|
<div>
|
|
<label for="admin-fee-address">Fee address (dove finisce il 30% di ogni round)</label>
|
|
<input id="admin-fee-address" class="mono" placeholder="plm1q...">
|
|
<label for="admin-bet-amount">Bet amount (PLM)</label>
|
|
<input id="admin-bet-amount" inputmode="decimal" placeholder="es. 10">
|
|
<label for="admin-min-amount">Importo minimo deposito/prelievo (PLM)</label>
|
|
<input id="admin-min-amount" inputmode="decimal" placeholder="es. 1">
|
|
</div>
|
|
<div>
|
|
<label for="admin-round-duration">Durata round (secondi)</label>
|
|
<input id="admin-round-duration" inputmode="numeric" placeholder="es. 600">
|
|
<label for="admin-round-cooldown">Pausa tra un round e il successivo (secondi)</label>
|
|
<input id="admin-round-cooldown" inputmode="numeric" placeholder="es. 30">
|
|
<label for="admin-draw-animation">Durata animazione estrazione (secondi)</label>
|
|
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
|
|
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
|
|
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
|
|
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
|
|
<input id="admin-rbf-timeout" inputmode="numeric" placeholder="es. 900">
|
|
</div>
|
|
</div>
|
|
<button onclick="adminSave()" id="save-btn">Salva</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="view" id="view-utenti">
|
|
<h2 class="section-title">Utenti</h2>
|
|
<p class="hint">Elenco utenti registrati, con saldo interno, accesso alla chiave privata per interventi manuali (es. restituire fondi bloccati) e reset password per chi resta bloccato fuori dall'account.</p>
|
|
|
|
<div class="warning-banner">
|
|
⚠ La chiave privata dà accesso completo ai fondi dell'utente: ogni visualizzazione viene registrata nell'audit log, non condividerla né salvarla altrove. La password esistente di un utente non è mai recuperabile (è salvata solo come hash Argon2) — "Reset" ne genera una nuova al posto della vecchia, anche questo audit-loggato.
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr><th>ID</th><th>Username</th><th>Indirizzo</th><th>Saldo (PLM)</th><th>Registrato</th><th>Chiave</th><th>Password</th></tr>
|
|
</thead>
|
|
<tbody id="users-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="view" id="view-round">
|
|
<h2 class="section-title">Round</h2>
|
|
<p class="hint">Ultimi round: stato, vincitore, importi e transazione di payout.</p>
|
|
|
|
<div class="card">
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr><th>ID</th><th>Stato</th><th>Apertura</th><th>Vincitore</th><th>Pool (PLM)</th><th>Vincita (PLM)</th><th>Fee (PLM)</th><th>Payout txid</th></tr>
|
|
</thead>
|
|
<tbody id="rounds-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="view" id="view-pending">
|
|
<h2 class="section-title">Transazioni pendenti</h2>
|
|
<p class="hint">Bet, payout e prelievi non ancora confermati — candidati al fee-bump RBF se scade il timeout.</p>
|
|
|
|
<div class="card">
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr><th>ID</th><th>Tipo</th><th>Stato</th><th>Txid</th><th>Fee rate</th><th>Tentativi</th><th>Trasmessa</th></tr>
|
|
</thead>
|
|
<tbody id="pending-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="view" id="view-audit">
|
|
<h2 class="section-title">Audit log</h2>
|
|
<p class="hint">Ultimi eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.).</p>
|
|
|
|
<div class="card">
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr><th>ID</th><th>Evento</th><th>Dettagli</th><th>Utente</th><th>Round</th><th>Quando</th></tr>
|
|
</thead>
|
|
<tbody id="audit-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
<div id="toast-container" aria-live="polite"></div>
|
|
|
|
<script>
|
|
const SATS_PER_PLM = 100000000;
|
|
let adminToken = sessionStorage.getItem('plm_admin_token');
|
|
|
|
function toast(message, type) {
|
|
const container = document.getElementById('toast-container');
|
|
const el = document.createElement('div');
|
|
el.className = 'toast ' + type;
|
|
el.textContent = message;
|
|
container.appendChild(el);
|
|
setTimeout(() => el.remove(), 4000);
|
|
}
|
|
|
|
async function withLoading(button, label, fn) {
|
|
const original = button.textContent;
|
|
button.disabled = true;
|
|
button.textContent = label;
|
|
try {
|
|
await fn();
|
|
} finally {
|
|
button.disabled = false;
|
|
button.textContent = original;
|
|
}
|
|
}
|
|
|
|
async function callAdmin(method, path, body) {
|
|
const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
|
|
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.detail || res.statusText);
|
|
return data;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
|
|
function badge(status) {
|
|
return `<span class="badge status-${escapeHtml(status)}">${escapeHtml(status)}</span>`;
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString('it-IT');
|
|
}
|
|
|
|
const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
|
|
const CHAIN_STATUS_LABELS = {
|
|
waiting: 'In attesa del prossimo round',
|
|
open: 'Round aperto',
|
|
drawing: 'Estrazione in corso',
|
|
};
|
|
let chainStatusInterval = null;
|
|
|
|
async function refreshChainStatus() {
|
|
try {
|
|
const res = await fetch('/rounds/current');
|
|
const data = await res.json();
|
|
let statusKey;
|
|
if (!data.round_id) statusKey = 'waiting';
|
|
else if (DRAWING_STATUSES.includes(data.status)) statusKey = 'drawing';
|
|
else statusKey = 'open';
|
|
document.getElementById('chain-status-dot').className = 'status-dot status-' + statusKey;
|
|
document.getElementById('chain-status-label').textContent = CHAIN_STATUS_LABELS[statusKey];
|
|
document.getElementById('chain-block').textContent =
|
|
'Blocco ' + (data.chain_tip_height != null ? '#' + data.chain_tip_height : '—');
|
|
} catch (e) {
|
|
// leave the last-known status on screen rather than blanking it out
|
|
}
|
|
}
|
|
|
|
function startChainStatusPolling() {
|
|
refreshChainStatus();
|
|
clearInterval(chainStatusInterval);
|
|
chainStatusInterval = setInterval(refreshChainStatus, 15000);
|
|
}
|
|
|
|
function stopChainStatusPolling() {
|
|
clearInterval(chainStatusInterval);
|
|
chainStatusInterval = null;
|
|
}
|
|
|
|
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit'];
|
|
|
|
function switchView(name) {
|
|
for (const key of VIEWS) {
|
|
document.getElementById('nav-' + key).classList.toggle('active', key === name);
|
|
document.getElementById('view-' + key).classList.toggle('active', key === name);
|
|
}
|
|
const loaders = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog };
|
|
if (loaders[name]) loaders[name]();
|
|
}
|
|
|
|
function showDashboard() {
|
|
document.getElementById('login-section').classList.add('hidden');
|
|
document.getElementById('dashboard-section').classList.remove('hidden');
|
|
startChainStatusPolling();
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog()]);
|
|
}
|
|
|
|
async function adminLogin() {
|
|
const btn = document.getElementById('login-btn');
|
|
adminToken = document.getElementById('admin-token').value;
|
|
await withLoading(btn, 'Verifica…', async () => {
|
|
try {
|
|
await callAdmin('GET', '/admin/config');
|
|
sessionStorage.setItem('plm_admin_token', adminToken);
|
|
showDashboard();
|
|
await loadDashboard();
|
|
} catch (e) {
|
|
adminToken = null;
|
|
toast('Token non valido.', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function adminLogout() {
|
|
stopChainStatusPolling();
|
|
sessionStorage.removeItem('plm_admin_token');
|
|
adminToken = null;
|
|
document.getElementById('admin-token').value = '';
|
|
document.getElementById('dashboard-section').classList.add('hidden');
|
|
document.getElementById('login-section').classList.remove('hidden');
|
|
}
|
|
|
|
async function adminLoadConfig() {
|
|
try {
|
|
const data = await callAdmin('GET', '/admin/config');
|
|
document.getElementById('admin-fee-address').value = data.fee_address;
|
|
document.getElementById('admin-bet-amount').value = data.bet_amount_sats / SATS_PER_PLM;
|
|
document.getElementById('admin-round-duration').value = data.round_duration_seconds;
|
|
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
|
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
|
|
document.getElementById('admin-min-amount').value = data.min_amount_sats / SATS_PER_PLM;
|
|
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
|
|
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
|
|
renderMaintenanceState(data.paused);
|
|
} catch (e) {
|
|
toast('Errore nel caricamento configurazione: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderMaintenanceState(paused) {
|
|
const dot = document.getElementById('maintenance-dot');
|
|
const label = document.getElementById('maintenance-status-label');
|
|
const btn = document.getElementById('maintenance-btn');
|
|
btn.dataset.paused = paused ? '1' : '0';
|
|
if (paused) {
|
|
dot.className = 'status-dot status-paused';
|
|
label.textContent = 'In pausa: nessun nuovo round verrà aperto';
|
|
btn.textContent = 'Riprendi lotteria';
|
|
btn.classList.remove('btn-stop');
|
|
} else {
|
|
dot.className = 'status-dot status-open';
|
|
label.textContent = 'Attiva: i round si susseguono normalmente';
|
|
btn.textContent = 'Interrompi dopo questo round';
|
|
btn.classList.add('btn-stop');
|
|
}
|
|
}
|
|
|
|
async function toggleMaintenance() {
|
|
const btn = document.getElementById('maintenance-btn');
|
|
const isPaused = btn.dataset.paused === '1';
|
|
const path = isPaused ? '/admin/resume' : '/admin/pause';
|
|
if (!isPaused && !window.confirm(
|
|
"Nessun nuovo round verrà aperto dopo quello in corso, fino a quando non riprendi la lotteria. " +
|
|
"Il round attuale (se presente) verrà comunque completato e il vincitore pagato. Continuare?"
|
|
)) {
|
|
return;
|
|
}
|
|
btn.disabled = true;
|
|
try {
|
|
const data = await callAdmin('POST', path, {});
|
|
renderMaintenanceState(data.paused);
|
|
toast(data.paused ? 'Lotteria in pausa.' : 'Lotteria ripresa.', 'success');
|
|
refreshChainStatus();
|
|
} catch (e) {
|
|
toast('Errore: ' + e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function adminSave() {
|
|
const btn = document.getElementById('save-btn');
|
|
const feeAddress = document.getElementById('admin-fee-address').value;
|
|
const betAmountPlm = parseFloat(document.getElementById('admin-bet-amount').value);
|
|
const minAmountPlm = parseFloat(document.getElementById('admin-min-amount').value);
|
|
const body = {
|
|
fee_address: feeAddress,
|
|
bet_amount_sats: Math.round(betAmountPlm * SATS_PER_PLM),
|
|
round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10),
|
|
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
|
|
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
|
|
min_amount_sats: Math.round(minAmountPlm * SATS_PER_PLM),
|
|
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
|
|
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
|
|
};
|
|
await withLoading(btn, 'Salvataggio…', async () => {
|
|
try {
|
|
await callAdmin('PUT', '/admin/config', body);
|
|
toast('Configurazione salvata.', 'success');
|
|
} catch (e) {
|
|
toast('Errore nel salvataggio: ' + e.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadUsers() {
|
|
try {
|
|
const users = await callAdmin('GET', '/admin/users');
|
|
const tbody = document.getElementById('users-tbody');
|
|
tbody.innerHTML = users.map((u) => `
|
|
<tr>
|
|
<td>${u.id}</td>
|
|
<td>${escapeHtml(u.username)}</td>
|
|
<td class="addr">${escapeHtml(u.address)}</td>
|
|
<td>${u.balance_sats / SATS_PER_PLM}</td>
|
|
<td>${fmtDate(u.created_at)}</td>
|
|
<td>
|
|
<button class="reveal" onclick="revealPrivkey(${u.id}, this)">Mostra</button>
|
|
<div class="privkey-box hidden" id="privkey-${u.id}"></div>
|
|
</td>
|
|
<td>
|
|
<button class="secondary" style="width:auto;margin-top:0;min-height:30px;padding:4px 10px;font-size:0.78rem" onclick="resetUserPassword(${u.id}, this)">Reset</button>
|
|
<div class="privkey-box hidden" id="newpass-${u.id}"></div>
|
|
</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="7" class="hint">Nessun utente registrato.</td></tr>';
|
|
} catch (e) {
|
|
toast('Errore nel caricamento utenti: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function revealPrivkey(userId, button) {
|
|
const box = document.getElementById('privkey-' + userId);
|
|
if (!box.classList.contains('hidden')) {
|
|
box.classList.add('hidden');
|
|
box.textContent = '';
|
|
button.textContent = 'Mostra';
|
|
return;
|
|
}
|
|
if (!window.confirm('Stai per visualizzare la chiave privata di questo utente. L\'accesso verrà registrato nell\'audit log. Continuare?')) {
|
|
return;
|
|
}
|
|
await withLoading(button, '…', async () => {
|
|
try {
|
|
const data = await callAdmin('GET', '/admin/users/' + userId + '/privkey');
|
|
box.textContent = data.wif;
|
|
box.classList.remove('hidden');
|
|
button.textContent = 'Nascondi';
|
|
} catch (e) {
|
|
toast('Errore: ' + e.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function resetUserPassword(userId, button) {
|
|
if (!window.confirm(
|
|
"Verrà generata una nuova password casuale per questo utente, che non potrà più accedere con quella vecchia. " +
|
|
"L'azione viene registrata nell'audit log. Continuare?"
|
|
)) {
|
|
return;
|
|
}
|
|
const box = document.getElementById('newpass-' + userId);
|
|
await withLoading(button, '…', async () => {
|
|
try {
|
|
const data = await callAdmin('POST', '/admin/users/' + userId + '/reset-password');
|
|
box.textContent = 'Nuova password per ' + data.username + ': ' + data.new_password;
|
|
box.classList.remove('hidden');
|
|
toast('Password reimpostata.', 'success');
|
|
} catch (e) {
|
|
toast('Errore: ' + e.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadRounds() {
|
|
try {
|
|
const rounds = await callAdmin('GET', '/admin/rounds');
|
|
const tbody = document.getElementById('rounds-tbody');
|
|
tbody.innerHTML = rounds.map((r) => `
|
|
<tr>
|
|
<td>${r.id}</td>
|
|
<td>${badge(r.status)}</td>
|
|
<td>${fmtDate(r.opened_at)}</td>
|
|
<td>${r.winner_username ? escapeHtml(r.winner_username) : '—'}</td>
|
|
<td>${r.pool_amount_sats != null ? r.pool_amount_sats / SATS_PER_PLM : '—'}</td>
|
|
<td>${r.winner_amount_sats != null ? r.winner_amount_sats / SATS_PER_PLM : '—'}</td>
|
|
<td>${r.fee_amount_sats != null ? r.fee_amount_sats / SATS_PER_PLM : '—'}</td>
|
|
<td class="txid">${r.payout_txid ? escapeHtml(r.payout_txid) : '—'}</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="8" class="hint">Nessun round ancora.</td></tr>';
|
|
} catch (e) {
|
|
toast('Errore nel caricamento round: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadPending() {
|
|
try {
|
|
const items = await callAdmin('GET', '/admin/pending-transactions');
|
|
const tbody = document.getElementById('pending-tbody');
|
|
tbody.innerHTML = items.map((p) => `
|
|
<tr>
|
|
<td>${p.id}</td>
|
|
<td>${escapeHtml(p.kind)}</td>
|
|
<td>${badge(p.status)}</td>
|
|
<td class="txid">${escapeHtml(p.current_txid)}</td>
|
|
<td>${p.fee_rate_sat_vb} sat/vB</td>
|
|
<td>${p.attempt_count}</td>
|
|
<td>${fmtDate(p.broadcast_at)}</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="7" class="hint">Nessuna transazione pendente.</td></tr>';
|
|
} catch (e) {
|
|
toast('Errore nel caricamento transazioni pendenti: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadAuditLog() {
|
|
try {
|
|
const entries = await callAdmin('GET', '/admin/audit-log');
|
|
const tbody = document.getElementById('audit-tbody');
|
|
tbody.innerHTML = entries.map((e) => `
|
|
<tr>
|
|
<td>${e.id}</td>
|
|
<td>${escapeHtml(e.event_type)}</td>
|
|
<td><pre class="payload">${escapeHtml(JSON.stringify(e.payload))}</pre></td>
|
|
<td>${e.user_id ?? '—'}</td>
|
|
<td>${e.round_id ?? '—'}</td>
|
|
<td>${fmtDate(e.created_at)}</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="6" class="hint">Nessun evento registrato.</td></tr>';
|
|
} catch (e) {
|
|
toast('Errore nel caricamento audit log: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
document.getElementById('admin-token').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') adminLogin();
|
|
});
|
|
|
|
function initAuthState() {
|
|
adminToken = sessionStorage.getItem('plm_admin_token');
|
|
if (!adminToken) {
|
|
document.getElementById('dashboard-section').classList.add('hidden');
|
|
document.getElementById('login-section').classList.remove('hidden');
|
|
return;
|
|
}
|
|
callAdmin('GET', '/admin/config')
|
|
.then(() => { showDashboard(); return loadDashboard(); })
|
|
.catch(() => adminLogout());
|
|
}
|
|
|
|
// Bfcache can restore a frozen snapshot of this page (DOM/JS state as it was
|
|
// before navigating away) without re-running any of this script — so a stale
|
|
// view could survive across back/forward navigation, e.g. showing a dashboard
|
|
// for a token that's since been rotated or explicitly logged out of. Cache-
|
|
// Control: no-store on this response should already prevent that, but
|
|
// re-validate here too as a safety net for browsers that ignore it.
|
|
window.addEventListener('pageshow', (event) => {
|
|
if (event.persisted) initAuthState();
|
|
});
|
|
|
|
initAuthState();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|