Adds app/static/logo.svg — the official Palladium logo (fetched from the palladium-coin/palladium-web-site repo), reprocessed for crisp display at small sizes: the white/blue boundary is sharpened (steep contrast curve instead of the soft anti-aliasing baked into the low-res source) and the outer circle uses an analytically computed alpha edge instead of a rasterized ellipse, so it stays perfectly round with no pixel staircase at any size. Shape and colors are otherwise identical to the source artwork. Wired up as the navbar brand mark (replacing the plain amber "P" square) in index.html, and as the favicon for both index.html and admin.html. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
717 lines
28 KiB
HTML
717 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>
|
|
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
|
<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>
|