2026-07-21 10:36:55 +02:00
<!doctype html>
< html lang = "it" >
< head >
< meta charset = "utf-8" >
2026-07-21 10:46:55 +02:00
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > PLM Lottery — Admin</ title >
2026-07-22 15:39:47 +02:00
< link rel = "icon" type = "image/svg+xml" href = "/logo.svg" >
2026-07-21 10:36:55 +02:00
< style >
2026-07-21 10:46:55 +02:00
@ 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 : 12 px ;
}
* { box-sizing : border-box ; }
body {
font-family : 'Fira Sans' , system-ui , sans-serif ;
background : var ( -- color - background );
color : var ( -- color - foreground );
2026-07-21 15:06:38 +02:00
margin : 0 ;
2026-07-21 10:46:55 +02:00
line-height : 1.5 ;
}
. mono { font-family : 'Fira Code' , monospace ; }
2026-07-21 15:06:38 +02:00
/* --- login screen: narrow, centered --- */
# login-section { max-width : 420 px ; margin : 15 vh auto 0 ; padding : 0 20 px ; }
# login-section header { margin-bottom : 24 px ; }
# login-section header h1 { font-size : 1.375 rem ; font-weight : 700 ; margin : 0 ; letter-spacing : -0.01 em ; }
# login-section header p { color : var ( -- color - muted - foreground ); font-size : 0.9 rem ; margin : 4 px 0 0 ; }
/* --- dashboard: navbar + content --- */
. navbar {
display : flex ; align-items : center ; gap : 4 px ; flex-wrap : wrap ;
background : var ( -- color - surface ); border-bottom : 1 px solid var ( -- color - border );
padding : 0 20 px ; position : sticky ; top : 0 ; z-index : 10 ;
}
. navbar . brand { font-weight : 700 ; font-size : 1.05 rem ; padding : 14 px 16 px 14 px 0 ; white-space : nowrap ; }
. navbar . nav-tab {
padding : 16 px 14 px ; font-size : 0.9 rem ; font-weight : 600 ; cursor : pointer ;
color : var ( -- color - muted - foreground ); border-bottom : 2 px solid transparent ;
margin-bottom : -1 px ; transition : color 150 ms , border-color 150 ms ; 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 ; }
2026-07-22 10:16:51 +02:00
. chain-status-pill { display : inline-flex ; align-items : center ; gap : 7 px ; font-weight : 600 ; font-size : 0.82 rem ; white-space : nowrap ; }
. status-dot {
width : 8 px ; height : 8 px ; border-radius : 50 % ; flex-shrink : 0 ;
background : var ( -- color - muted - foreground );
}
. status-dot . status-open {
background : var ( -- color - success );
box-shadow : 0 0 0 3 px color-mix ( in srgb , var ( -- color - success ) 18 % , transparent );
}
. status-dot . status-drawing {
background : var ( -- color - primary );
animation : status-dot-pulse 1400 ms 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 5 px transparent ; }
}
. chain-block { color : var ( -- color - muted - foreground ); font-size : 0.82 rem ; white-space : nowrap ; }
2026-07-22 10:36:36 +02:00
. row-between { display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; }
# 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 ); }
2026-07-21 15:06:38 +02:00
main { max-width : 960 px ; margin : 0 auto ; padding : 24 px 20 px 80 px ; }
. view { display : none ; }
. view . active { display : block ; }
h2 . section-title { font-size : 1.15 rem ; font-weight : 700 ; margin : 0 0 4 px ; }
2026-07-21 10:46:55 +02:00
. card {
background : var ( -- color - surface );
border : 1 px solid var ( -- color - border );
border-radius : var ( -- radius );
padding : 20 px ;
margin-bottom : 16 px ;
}
. card . hint { color : var ( -- color - muted - foreground ); font-size : 0.85 rem ; margin : 0 0 14 px ; }
2026-07-21 15:06:38 +02:00
. grid-2 { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 220 px , 1 fr )); gap : 0 20 px ; }
2026-07-21 10:46:55 +02:00
label { display : block ; font-size : 0.85 rem ; font-weight : 500 ; color : var ( -- color - muted - foreground ); margin-top : 12 px ; margin-bottom : 6 px ; }
label : first-child { margin-top : 0 ; }
input {
width : 100 % ; padding : 10 px 12 px ; font-size : 0.95 rem ; font-family : inherit ;
border : 1 px solid var ( -- color - border ); border-radius : 8 px ; background : var ( -- color - surface );
color : var ( -- color - foreground ); transition : border-color 150 ms , box-shadow 150 ms ;
}
input : focus {
outline : none ; border-color : var ( -- color - ring );
box-shadow : 0 0 0 3 px color-mix ( in srgb , var ( -- color - ring ) 25 % , transparent );
}
button {
display : inline-flex ; align-items : center ; justify-content : center ; gap : 6 px ;
min-height : 44 px ; padding : 0 18 px ; margin-top : 16 px ; width : 100 % ;
font-family : inherit ; font-size : 0.95 rem ; font-weight : 600 ;
background : var ( -- color - primary ); color : var ( -- color - on - primary );
border : none ; border-radius : 8 px ; cursor : pointer ;
transition : filter 150 ms , transform 150 ms ;
}
button : hover { filter : brightness ( 0.94 ); }
button : active { transform : scale ( 0.98 ); }
button : disabled { opacity : 0.6 ; cursor : default ; }
button : focus-visible { outline : 2 px solid var ( -- color - ring ); outline-offset : 2 px ; }
button . secondary {
2026-07-21 15:06:38 +02:00
width : auto ; margin-top : 0 ; min-height : 36 px ; padding : 0 14 px ;
2026-07-21 10:46:55 +02:00
background : var ( -- color - background ); color : var ( -- color - foreground );
border : 1 px solid var ( -- color - border );
}
. hidden { display : none !important ; }
2026-07-21 14:22:03 +02:00
table { width : 100 % ; border-collapse : collapse ; font-size : 0.85 rem ; }
th , td { text-align : left ; padding : 8 px 6 px ; border-bottom : 1 px solid var ( -- color - border ); vertical-align : top ; }
th { color : var ( -- color - muted - foreground ); font-weight : 500 ; }
2026-07-21 15:06:38 +02:00
td . addr , td . txid { font-family : 'Fira Code' , monospace ; word-break : break-all ; max-width : 200 px ; }
. table-wrap { overflow-x : auto ; }
. badge {
display : inline-block ; font-size : 0.72 rem ; font-weight : 600 ; padding : 2 px 8 px ;
border-radius : 999 px ; background : var ( -- color - background ); border : 1 px 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 ;
}
2026-07-21 14:22:03 +02:00
button . reveal {
width : auto ; margin-top : 0 ; padding : 4 px 10 px ; min-height : 30 px ; font-size : 0.78 rem ;
background : var ( -- color - destructive - bg ); color : var ( -- color - destructive ); border : 1 px solid var ( -- color - destructive );
}
. privkey-box {
margin-top : 6 px ; padding : 8 px ; border-radius : 6 px ; font-size : 0.78 rem ;
background : var ( -- color - destructive - bg ); border : 1 px 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 : 1 px solid var ( -- color - destructive ); color : var ( -- color - destructive );
border-radius : 8 px ; padding : 10 px 12 px ; font-size : 0.8 rem ; margin-bottom : 14 px ; font-weight : 500 ;
}
2026-07-21 15:06:38 +02:00
pre . payload {
background : var ( -- color - background ); border : 1 px solid var ( -- color - border ); border-radius : 6 px ;
padding : 6 px 8 px ; font-size : 0.75 rem ; margin : 0 ; white-space : pre-wrap ; word-break : break-all ;
}
2026-07-21 10:46:55 +02:00
# toast-container {
position : fixed ; bottom : 20 px ; left : 50 % ; transform : translateX ( -50 % );
display : flex ; flex-direction : column ; gap : 8 px ; z-index : 100 ; width : calc ( 100 % - 40 px ); max-width : 440 px ;
}
. toast {
padding : 12 px 14 px ; border-radius : 8 px ; font-size : 0.85 rem ; font-weight : 500 ;
box-shadow : 0 4 px 12 px rgba ( 15 , 23 , 42 , 0.12 );
animation : toast-in 200 ms 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 ( 8 px ); } to { opacity : 1 ; transform : translateY ( 0 ); } }
@ media ( prefers-reduced-motion : reduce ) {
* { animation : none !important ; transition : none !important ; }
}
2026-07-21 10:36:55 +02:00
</ style >
</ head >
< body >
2026-07-21 14:28:29 +02:00
< section id = "login-section" >
< header >
< h1 > PLM Lottery — Admin</ h1 >
< p > Accesso riservato</ p >
</ header >
2026-07-21 10:36:55 +02:00
2026-07-21 14:28:29 +02:00
< 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 >
2026-07-21 10:36:55 +02:00
2026-07-21 15:06:38 +02:00
< 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 >
2026-07-22 10:16:51 +02:00
< 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 >
2026-07-21 15:06:38 +02:00
</ nav >
2026-07-21 10:36:55 +02:00
2026-07-21 15:06:38 +02:00
< main >
2026-07-21 10:36:55 +02:00
2026-07-21 15:06:38 +02:00
< 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 >
2026-07-21 10:36:55 +02:00
2026-07-22 10:36:36 +02:00
< 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 >
2026-07-21 15:06:38 +02:00
< 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" >
2026-07-21 16:03:50 +02:00
< label for = "admin-draw-animation" > Durata animazione estrazione (secondi)</ label >
< input id = "admin-draw-animation" inputmode = "numeric" placeholder = "es. 20" >
2026-07-21 15:06:38 +02:00
< 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 >
2026-07-21 14:28:29 +02:00
</ div >
2026-07-21 15:06:38 +02:00
< div class = "view" id = "view-utenti" >
< h2 class = "section-title" > Utenti</ h2 >
2026-07-22 12:00:09 +02:00
< 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 >
2026-07-21 15:06:38 +02:00
< div class = "warning-banner" >
2026-07-22 12:00:09 +02:00
⚠ 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.
2026-07-21 15:06:38 +02:00
</ div >
< div class = "card" >
< div class = "table-wrap" >
< table >
< thead >
2026-07-22 12:00:09 +02:00
< tr >< th > ID</ th >< th > Username</ th >< th > Indirizzo</ th >< th > Saldo (PLM)</ th >< th > Registrato</ th >< th > Chiave</ th >< th > Password</ th ></ tr >
2026-07-21 15:06:38 +02:00
</ thead >
< tbody id = "users-tbody" ></ tbody >
</ table >
</ div >
</ div >
2026-07-21 14:28:29 +02:00
</ div >
2026-07-21 15:06:38 +02:00
< 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 >
2026-07-21 14:22:03 +02:00
2026-07-21 10:46:55 +02:00
< div id = "toast-container" aria-live = "polite" ></ div >
2026-07-21 10:36:55 +02:00
< script >
2026-07-21 10:46:55 +02:00
const SATS_PER_PLM = 100000000 ;
2026-07-21 14:28:29 +02:00
let adminToken = sessionStorage . getItem ( 'plm_admin_token' );
2026-07-21 10:46:55 +02:00
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 ;
}
2026-07-21 10:36:55 +02:00
}
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 ;
}
2026-07-21 15:06:38 +02:00
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' );
}
2026-07-22 10:16:51 +02:00
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 ;
}
2026-07-21 15:06:38 +02:00
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 ]();
}
2026-07-21 14:28:29 +02:00
function showDashboard () {
document . getElementById ( 'login-section' ). classList . add ( 'hidden' );
document . getElementById ( 'dashboard-section' ). classList . remove ( 'hidden' );
2026-07-22 10:16:51 +02:00
startChainStatusPolling ();
2026-07-21 14:28:29 +02:00
}
async function loadDashboard () {
2026-07-21 15:06:38 +02:00
await Promise . all ([ adminLoadConfig (), loadUsers (), loadRounds (), loadPending (), loadAuditLog ()]);
2026-07-21 14:28:29 +02:00
}
async function adminLogin () {
const btn = document . getElementById ( 'login-btn' );
adminToken = document . getElementById ( 'admin-token' ). value ;
await withLoading ( btn , 'Verifica…' , async () => {
2026-07-21 10:46:55 +02:00
try {
2026-07-21 14:28:29 +02:00
await callAdmin ( 'GET' , '/admin/config' );
sessionStorage . setItem ( 'plm_admin_token' , adminToken );
showDashboard ();
await loadDashboard ();
2026-07-21 10:46:55 +02:00
} catch ( e ) {
2026-07-21 14:28:29 +02:00
adminToken = null ;
toast ( 'Token non valido.' , 'error' );
2026-07-21 10:46:55 +02:00
}
});
2026-07-21 10:36:55 +02:00
}
2026-07-21 14:28:29 +02:00
function adminLogout () {
2026-07-22 10:16:51 +02:00
stopChainStatusPolling ();
2026-07-21 14:28:29 +02:00
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 ;
2026-07-21 15:06:38 +02:00
document . getElementById ( 'admin-round-duration' ). value = data . round_duration_seconds ;
document . getElementById ( 'admin-round-cooldown' ). value = data . round_cooldown_seconds ;
2026-07-21 16:03:50 +02:00
document . getElementById ( 'admin-draw-animation' ). value = data . draw_animation_seconds ;
2026-07-21 15:06:38 +02:00
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 ;
2026-07-22 10:36:36 +02:00
renderMaintenanceState ( data . paused );
2026-07-21 14:28:29 +02:00
} catch ( e ) {
toast ( 'Errore nel caricamento configurazione: ' + e . message , 'error' );
}
}
2026-07-22 10:36:36 +02:00
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 ;
}
}
2026-07-21 10:36:55 +02:00
async function adminSave () {
2026-07-21 10:46:55 +02:00
const btn = document . getElementById ( 'save-btn' );
2026-07-21 10:36:55 +02:00
const feeAddress = document . getElementById ( 'admin-fee-address' ). value ;
2026-07-21 10:38:26 +02:00
const betAmountPlm = parseFloat ( document . getElementById ( 'admin-bet-amount' ). value );
2026-07-21 15:06:38 +02:00
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 ),
2026-07-21 16:03:50 +02:00
draw_animation_seconds : parseInt ( document . getElementById ( 'admin-draw-animation' ). value , 10 ),
2026-07-21 15:06:38 +02:00
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 ),
};
2026-07-21 10:46:55 +02:00
await withLoading ( btn , 'Salvataggio…' , async () => {
try {
2026-07-21 15:06:38 +02:00
await callAdmin ( 'PUT' , '/admin/config' , body );
2026-07-21 10:46:55 +02:00
toast ( 'Configurazione salvata.' , 'success' );
} catch ( e ) {
toast ( 'Errore nel salvataggio: ' + e . message , 'error' );
}
});
2026-07-21 10:36:55 +02:00
}
2026-07-21 14:22:03 +02:00
async function loadUsers () {
2026-07-21 14:28:29 +02:00
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>
2026-07-21 15:06:38 +02:00
<td> ${ fmtDate ( u . created_at ) } </td>
2026-07-21 14:28:29 +02:00
<td>
<button class="reveal" onclick="revealPrivkey( ${ u . id } , this)">Mostra</button>
<div class="privkey-box hidden" id="privkey- ${ u . id } "></div>
</td>
2026-07-22 12:00:09 +02:00
<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>
2026-07-21 14:28:29 +02:00
</tr>
2026-07-22 12:00:09 +02:00
` ). join ( '' ) || '<tr><td colspan="7" class="hint">Nessun utente registrato.</td></tr>' ;
2026-07-21 14:28:29 +02:00
} catch ( e ) {
toast ( 'Errore nel caricamento utenti: ' + e . message , 'error' );
}
2026-07-21 14:22:03 +02:00
}
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' );
}
});
}
2026-07-21 14:28:29 +02:00
2026-07-22 12:00:09 +02:00
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' );
}
});
}
2026-07-21 15:06:38 +02:00
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' );
}
}
2026-07-21 14:28:29 +02:00
document . getElementById ( 'admin-token' ). addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' ) adminLogin ();
});
2026-07-22 12:00:09 +02:00
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 ;
}
2026-07-21 14:28:29 +02:00
callAdmin ( 'GET' , '/admin/config' )
. then (() => { showDashboard (); return loadDashboard (); })
2026-07-22 12:00:09 +02:00
. catch (() => adminLogout ());
2026-07-21 14:28:29 +02:00
}
2026-07-22 12:00:09 +02:00
// 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 ();
2026-07-21 10:36:55 +02:00
</ script >
</ body >
</ html >