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-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 );
max-width : 480 px ;
margin : 0 auto ;
padding : 32 px 20 px 80 px ;
line-height : 1.5 ;
}
. mono { font-family : 'Fira Code' , monospace ; }
header { margin-bottom : 24 px ; }
header h1 { font-size : 1.375 rem ; font-weight : 700 ; margin : 0 ; letter-spacing : -0.01 em ; }
header p { color : var ( -- color - muted - foreground ); font-size : 0.9 rem ; margin : 4 px 0 0 ; }
. 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 ; }
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 {
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 ; }
td . addr { font-family : 'Fira Code' , monospace ; word-break : break-all ; max-width : 160 px ; }
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 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 10:46:55 +02:00
< header >
< h1 > PLM Lottery — Admin</ h1 >
< p > Configurazione operativa del round</ p >
</ header >
2026-07-21 10:36:55 +02:00
2026-07-21 10:46:55 +02:00
< div class = "card" >
< p class = "hint" >
Salvata nel database, modificabile in qualsiasi momento senza riavviare il server.
Serve il token admin (< code > ADMIN_TOKEN</ code > nel < code > .env</ code > del server).
2026-07-21 10:36:55 +02:00
</ p >
2026-07-21 10:46:55 +02:00
< label for = "admin-token" > Admin token</ label >
2026-07-21 10:36:55 +02:00
< input id = "admin-token" type = "password" placeholder = "valore di ADMIN_TOKEN" >
2026-07-21 10:46:55 +02:00
< button class = "secondary" onclick = "adminLoad()" id = "load-btn" > Carica configurazione attuale</ button >
2026-07-21 10:36:55 +02:00
< div id = "admin-form" class = "hidden" >
2026-07-21 10:46:55 +02:00
< 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" >
< button onclick = "adminSave()" id = "save-btn" > Salva</ button >
2026-07-21 10:36:55 +02:00
</ div >
2026-07-21 10:46:55 +02:00
</ div >
2026-07-21 10:36:55 +02:00
2026-07-21 14:22:03 +02:00
< div class = "card" >
< h2 style = "font-size:1rem;font-weight:600;margin:0 0 4px" > Utenti</ h2 >
< p class = "hint" > Elenco utenti registrati, con saldo interno e accesso alla chiave privata per interventi manuali (es. restituire fondi bloccati).</ p >
< div class = "warning-banner" >
⚠ La chiave privata dà accesso completo ai fondi dell'utente. Ogni volta che la visualizzi viene registrata nell'audit log del server. Non condividerla, non salvarla altrove.
</ div >
< button class = "secondary" onclick = "loadUsers()" id = "users-load-btn" > Carica utenti</ button >
< div id = "users-table-wrap" class = "hidden" style = "overflow-x:auto;margin-top:14px" >
< table >
< thead >
< tr >< th > ID</ th >< th > Username</ th >< th > Indirizzo</ th >< th > Saldo (PLM)</ th >< th > Chiave</ th ></ tr >
</ thead >
< tbody id = "users-tbody" ></ tbody >
</ table >
</ div >
</ div >
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 ;
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 adminToken = document . getElementById ( 'admin-token' ). value ;
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 ;
}
async function adminLoad () {
2026-07-21 10:46:55 +02:00
const btn = document . getElementById ( 'load-btn' );
await withLoading ( btn , 'Caricamento…' , async () => {
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-form' ). classList . remove ( 'hidden' );
toast ( 'Configurazione caricata.' , 'success' );
} catch ( e ) {
toast ( 'Errore: ' + e . message , 'error' );
}
});
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 );
const betAmountSats = Math . round ( betAmountPlm * SATS_PER_PLM );
2026-07-21 10:46:55 +02:00
await withLoading ( btn , 'Salvataggio…' , async () => {
try {
await callAdmin ( 'PUT' , '/admin/config' , { fee_address : feeAddress , bet_amount_sats : betAmountSats });
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
function escapeHtml ( s ) {
return s . replace ( /[&<>"']/g , ( c ) => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[ c ]));
}
async function loadUsers () {
const btn = document . getElementById ( 'users-load-btn' );
await withLoading ( btn , 'Caricamento…' , async () => {
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>
<button class="reveal" onclick="revealPrivkey( ${ u . id } , this)">Mostra</button>
<div class="privkey-box hidden" id="privkey- ${ u . id } "></div>
</td>
</tr>
` ). join ( '' );
document . getElementById ( 'users-table-wrap' ). classList . remove ( 'hidden' );
toast ( 'Utenti caricati (' + users . length + ').' , 'success' );
} catch ( e ) {
toast ( 'Errore: ' + 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' );
}
});
}
2026-07-21 10:36:55 +02:00
</ script >
</ body >
</ html >