main
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Skeet Loader — Admin</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:#0f1117;color:#e0e0e0;min-height:100vh}
|
||||
/* Login */
|
||||
#loginBox{max-width:380px;margin:100px auto;padding:40px 30px;background:#1a1d27;border-radius:12px;border:1px solid #2a2d37}
|
||||
#loginBox h1{text-align:center;font-size:22px;margin-bottom:8px;background:linear-gradient(90deg,#00b9ff,#ff01f7,#e8ff00);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
#loginBox .sub{text-align:center;font-size:12px;color:#666;margin-bottom:24px}
|
||||
#loginBox input{width:100%;padding:12px;margin-bottom:12px;background:#0f1117;border:1px solid #2a2d37;border-radius:6px;color:#fff;font-size:14px;outline:none}
|
||||
#loginBox input:focus{border-color:#00b9ff}
|
||||
#loginBox button{width:100%;padding:12px;background:linear-gradient(90deg,#00b9ff,#ff01f7);border:none;border-radius:6px;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
|
||||
#loginBox button:hover{opacity:0.9}
|
||||
#loginBox .err{color:#ff5050;font-size:13px;margin-top:8px;text-align:center}
|
||||
/* Dashboard */
|
||||
#dashboard{display:none;max-width:1200px;margin:0 auto;padding:20px}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding:16px 0;border-bottom:1px solid #2a2d37;margin-bottom:24px}
|
||||
header h1{font-size:20px;background:linear-gradient(90deg,#00b9ff,#ff01f7,#e8ff00);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
header .user{font-size:13px;color:#888;display:flex;gap:16px;align-items:center}
|
||||
header .user button{background:#2a2d37;color:#ccc;border:1px solid #3a3d47;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:12px}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:24px}
|
||||
.stat{background:#1a1d27;border:1px solid #2a2d37;border-radius:8px;padding:16px 20px}
|
||||
.stat .n{font-size:28px;font-weight:700}
|
||||
.stat .l{font-size:12px;color:#666;margin-top:4px}
|
||||
.tabs{display:flex;gap:8px;margin-bottom:16px;border-bottom:1px solid #2a2d37;padding-bottom:8px}
|
||||
.tab{padding:8px 16px;background:transparent;color:#888;border:none;cursor:pointer;font-size:13px;border-radius:4px 4px 0 0}
|
||||
.tab.active{background:#1a1d27;color:#fff;border:1px solid #2a2d37;border-bottom:1px solid #1a1d27}
|
||||
.toolbar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.toolbar button{background:#2a2d37;color:#ccc;border:1px solid #3a3d47;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:12px}
|
||||
.toolbar button:hover{background:#3a3d47}
|
||||
.toolbar input{background:#1a1d27;border:1px solid #2a2d37;color:#fff;padding:7px 12px;border-radius:4px;font-size:12px;outline:none;width:180px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;padding:10px 12px;border-bottom:2px solid #2a2d37;color:#888;font-weight:600}
|
||||
td{padding:9px 12px;border-bottom:1px solid #1a1d27}
|
||||
tr:hover td{background:#1a1d2733}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
|
||||
.badge-ok{background:#0a3;color:#fff}
|
||||
.badge-bad{background:#a00;color:#fff}
|
||||
.badge-warn{background:#a80;color:#fff}
|
||||
.badge-info{background:#049;color:#fff}
|
||||
.btn-sm{background:#2a2d37;color:#ccc;border:1px solid #3a3d47;padding:4px 10px;border-radius:3px;cursor:pointer;font-size:11px;margin-right:4px}
|
||||
.btn-sm:hover{background:#3a3d47}
|
||||
.btn-sm.danger{color:#ff5050;border-color:#a03030}
|
||||
.btn-sm.danger:hover{background:#3a1a1a}
|
||||
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:100;justify-content:center;align-items:center}
|
||||
.modal{background:#1a1d27;border:1px solid #2a2d37;border-radius:12px;padding:24px;min-width:350px}
|
||||
.modal h3{margin-bottom:16px}
|
||||
.modal input{width:100%;padding:10px;margin-bottom:10px;background:#0f1117;border:1px solid #2a2d37;border-radius:6px;color:#fff;font-size:13px}
|
||||
.modal .btns{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||||
.modal .btns button{padding:8px 18px;border-radius:6px;border:none;cursor:pointer;font-size:13px}
|
||||
.modal .btn-primary{background:linear-gradient(90deg,#00b9ff,#ff01f7);color:#fff}
|
||||
.modal .btn-cancel{background:#2a2d37;color:#ccc}
|
||||
.toast{position:fixed;top:20px;right:20px;background:#0a3;color:#fff;padding:12px 20px;border-radius:6px;font-size:13px;z-index:200;animation:fadein 0.3s}
|
||||
@keyframes fadein{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login -->
|
||||
<div id="loginBox">
|
||||
<h1>Skeet Loader</h1>
|
||||
<div class="sub">Admin Panel</div>
|
||||
<input id="loginUser" placeholder="Username" autofocus>
|
||||
<input id="loginPwd" type="password" placeholder="Password">
|
||||
<button onclick="doLogin()">Login</button>
|
||||
<div class="err" id="loginErr"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboard">
|
||||
<header>
|
||||
<h1>Skeet Admin</h1>
|
||||
<div class="user">
|
||||
<span id="adminUser"></span>
|
||||
<button onclick="logout()">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('users')">Users</button>
|
||||
<button class="tab" onclick="switchTab('logs')">Login Logs</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" id="userToolbar">
|
||||
<input id="searchInput" placeholder="Search username..." oninput="renderUsers()">
|
||||
<button onclick="showCreateModal()">+ Create User</button>
|
||||
<button onclick="loadUsers()">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
<table id="userTable"><thead><tr><th>ID</th><th>Username</th><th>Role</th><th>Status</th><th>HWID Bound</th><th>Created</th><th>Expires</th><th>Actions</th></tr></thead><tbody></tbody></table>
|
||||
|
||||
<table id="logTable" style="display:none"><thead><tr><th>Time</th><th>User</th><th>IP</th><th>Result</th><th>HWID Match</th><th>Reason</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div class="modal-overlay" id="createModal"><div class="modal">
|
||||
<h3>Create User</h3>
|
||||
<input id="newUser" placeholder="Username">
|
||||
<input id="newPwd" type="password" placeholder="Password">
|
||||
<input id="newExp" placeholder="Expiry (YYYY-MM-DD, optional)">
|
||||
<div class="btns">
|
||||
<button class="btn-cancel" onclick="closeModal('createModal')">Cancel</button>
|
||||
<button class="btn-primary" onclick="createUser()">Create</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast" style="display:none"></div>
|
||||
|
||||
<script>
|
||||
const API = ''; // same origin
|
||||
let token = localStorage.getItem('admin_token');
|
||||
let allUsers = [];
|
||||
let allLogs = [];
|
||||
let currentTab = 'users';
|
||||
|
||||
// ─── Init ──────────────────────────────
|
||||
if (token) {
|
||||
document.getElementById('loginBox').style.display = 'none';
|
||||
document.getElementById('dashboard').style.display = 'block';
|
||||
loadAll();
|
||||
}
|
||||
|
||||
// ─── Login ─────────────────────────────
|
||||
async function doLogin() {
|
||||
const u = document.getElementById('loginUser').value.trim();
|
||||
const p = document.getElementById('loginPwd').value;
|
||||
if (!u || !p) return;
|
||||
|
||||
const h = sha256(p);
|
||||
|
||||
try {
|
||||
const r = await fetch(API + '/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: u, password_hash: h,
|
||||
hwid_components: { cpu: 'admin', board: 'panel', disk: 'panel', mac: 'panel' }
|
||||
})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) { document.getElementById('loginErr').textContent = d.error || 'Login failed'; return; }
|
||||
|
||||
// Verify admin role
|
||||
const payload = JSON.parse(atob(d.access_token.split('.')[1]));
|
||||
if (payload.role !== 'admin') {
|
||||
document.getElementById('loginErr').textContent = 'Not an admin account';
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('admin_token', d.access_token);
|
||||
token = d.access_token;
|
||||
document.getElementById('loginBox').style.display = 'none';
|
||||
document.getElementById('dashboard').style.display = 'block';
|
||||
document.getElementById('adminUser').textContent = payload.username;
|
||||
loadAll();
|
||||
} catch(e) {
|
||||
document.getElementById('loginErr').textContent = 'Connection failed';
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('admin_token');
|
||||
token = null;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// ─── SHA-256 (pure JS — works without crypto.subtle, over HTTP) ──
|
||||
function sha256(msg) {
|
||||
function R(n,w){return((n>>>w)|(n<<(32-w)))>>>0}
|
||||
const K=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
||||
// UTF-8 encode
|
||||
const m=unescape(encodeURIComponent(msg));
|
||||
const bytes=[];for(let i=0;i<m.length;i++)bytes.push(m.charCodeAt(i)&0xFF);
|
||||
const l=bytes.length*8;
|
||||
bytes.push(0x80);while((bytes.length+8)%64!==0)bytes.push(0);
|
||||
// Big-endian 64-bit length (high 32 bits always 0 for <4GB messages)
|
||||
bytes.push(0);bytes.push(0);bytes.push(0);bytes.push(0);
|
||||
bytes.push((l>>>24)&0xFF);bytes.push((l>>>16)&0xFF);bytes.push((l>>>8)&0xFF);bytes.push(l&0xFF);
|
||||
const H=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
|
||||
for(let p=0;p<bytes.length;p+=64){
|
||||
const W=[];for(let t=0;t<16;t++)W[t]=(bytes[p+t*4]<<24)|(bytes[p+t*4+1]<<16)|(bytes[p+t*4+2]<<8)|bytes[p+t*4+3];
|
||||
for(let t=16;t<64;t++){const s0=R(W[t-15],7)^R(W[t-15],18)^(W[t-15]>>>3),s1=R(W[t-2],17)^R(W[t-2],19)^(W[t-2]>>>10);W[t]=(W[t-16]+s0+W[t-7]+s1)>>>0}
|
||||
let[a,b,c,d,e,f,g,h]=H;
|
||||
for(let t=0;t<64;t++){const S1=R(e,6)^R(e,11)^R(e,25),ch=(e&f)^(~e&g),t1=(h+S1+ch+K[t]+W[t])>>>0,S0=R(a,2)^R(a,13)^R(a,22),maj=(a&b)^(a&c)^(b&c),t2=(S0+maj)>>>0;h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0}
|
||||
H[0]=(H[0]+a)>>>0;H[1]=(H[1]+b)>>>0;H[2]=(H[2]+c)>>>0;H[3]=(H[3]+d)>>>0;H[4]=(H[4]+e)>>>0;H[5]=(H[5]+f)>>>0;H[6]=(H[6]+g)>>>0;H[7]=(H[7]+h)>>>0;
|
||||
}
|
||||
return H.map(v=>('0000000'+v.toString(16)).slice(-8)).join('');
|
||||
}
|
||||
|
||||
// ─── Load Data ─────────────────────────
|
||||
async function loadAll() {
|
||||
await Promise.all([loadUsers(), loadStats()]);
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/users', { headers: authHeader() });
|
||||
const d = await r.json();
|
||||
allUsers = d.users || [];
|
||||
renderUsers();
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/stats', { headers: authHeader() });
|
||||
const d = await r.json();
|
||||
document.getElementById('stats').innerHTML =
|
||||
`<div class="stat"><div class="n">${d.totalUsers}</div><div class="l">Total Users</div></div>
|
||||
<div class="stat"><div class="n" style="color:#0a3">${d.activeUsers}</div><div class="l">Active</div></div>
|
||||
<div class="stat"><div class="n" style="color:#0af">${d.logins24h}</div><div class="l">Logins (24h)</div></div>
|
||||
<div class="stat"><div class="n" style="color:#f55">${d.failures24h}</div><div class="l">Failures (24h)</div></div>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/logs', { headers: authHeader() });
|
||||
const d = await r.json();
|
||||
allLogs = d.logs || [];
|
||||
renderLogs();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return { 'Authorization': 'Bearer ' + token };
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────
|
||||
function renderUsers() {
|
||||
const q = (document.getElementById('searchInput')?.value || '').toLowerCase();
|
||||
let users = q ? allUsers.filter(u => u.username.toLowerCase().includes(q)) : allUsers;
|
||||
const tbody = document.querySelector('#userTable tbody');
|
||||
tbody.innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${u.id}</td>
|
||||
<td><b>${esc(u.username)}</b></td>
|
||||
<td><span class="badge ${u.role==='admin'?'badge-warn':'badge-info'}">${u.role}</span></td>
|
||||
<td><span class="badge ${u.is_active?'badge-ok':'badge-bad'}">${u.is_active?'Active':'Banned'}</span></td>
|
||||
<td>${u.hwid_bound_at || '<span style="color:#666">—</span>'}</td>
|
||||
<td>${fmtDate(u.created_at)}</td>
|
||||
<td>${u.expires_at ? fmtDate(u.expires_at) : '<span style="color:#666">Never</span>'}</td>
|
||||
<td>
|
||||
<button class="btn-sm" onclick="toggleUser(${u.id},${u.is_active})">${u.is_active?'Ban':'Unban'}</button>
|
||||
<button class="btn-sm danger" onclick="resetHwid(${u.id})">Reset HWID</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const tbody = document.querySelector('#logTable tbody');
|
||||
tbody.innerHTML = allLogs.map(l => `
|
||||
<tr>
|
||||
<td>${fmtDate(l.attempted_at)}</td>
|
||||
<td>${esc(l.username||'—')}</td>
|
||||
<td>${l.ip_address||'—'}</td>
|
||||
<td><span class="badge ${l.success?'badge-ok':'badge-bad'}">${l.success?'OK':'FAIL'}</span></td>
|
||||
<td>${l.hwid_match_rate != null ? (l.hwid_match_rate*100).toFixed(0)+'%' : '—'}</td>
|
||||
<td style="color:#888">${esc(l.reason||'')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ─── Tabs ──────────────────────────────
|
||||
function switchTab(t) {
|
||||
currentTab = t;
|
||||
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('userTable').style.display = t === 'users' ? '' : 'none';
|
||||
document.getElementById('userToolbar').style.display = t === 'users' ? '' : 'none';
|
||||
document.getElementById('logTable').style.display = t === 'logs' ? '' : 'none';
|
||||
if (t === 'logs') loadLogs();
|
||||
if (t === 'users') loadUsers();
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────
|
||||
function showCreateModal() {
|
||||
document.getElementById('createModal').style.display = 'flex';
|
||||
document.getElementById('newUser').focus();
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).style.display = 'none';
|
||||
document.getElementById('newUser').value = '';
|
||||
document.getElementById('newPwd').value = '';
|
||||
document.getElementById('newExp').value = '';
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
const u = document.getElementById('newUser').value.trim();
|
||||
const p = document.getElementById('newPwd').value;
|
||||
const e = document.getElementById('newExp').value;
|
||||
if (!u || !p) return;
|
||||
|
||||
// Admin creates user with raw password (server hashes internally)
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/create-user', {
|
||||
method: 'POST', headers: { ...authHeader(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: u, password: p, expires_at: e || null })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) { toast('User created: ' + u); closeModal('createModal'); loadUsers(); }
|
||||
else toast(d.error || 'Failed', true);
|
||||
} catch(e) { toast('Connection error', true); }
|
||||
}
|
||||
|
||||
async function toggleUser(id, current) {
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/user/' + id + '/toggle', {
|
||||
method: 'POST', headers: authHeader()
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) { toast('User ' + (d.is_active ? 'activated' : 'banned')); loadUsers(); }
|
||||
} catch(e) { toast('Error', true); }
|
||||
}
|
||||
|
||||
async function resetHwid(id) {
|
||||
if (!confirm('Reset HWID for this user? They can bind to a new machine on next login.')) return;
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/user/' + id + '/reset-hwid', {
|
||||
method: 'POST', headers: authHeader()
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) toast('HWID reset — user can re-bind'); else toast(d.error||'Failed', true);
|
||||
} catch(e) { toast('Error', true); }
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—';
|
||||
const d = new Date(s + (s.endsWith('Z')?'':'Z'));
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function toast(msg, isErr) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.style.background = isErr ? '#a00' : '#0a3';
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 3000);
|
||||
}
|
||||
|
||||
// Close modal on overlay click
|
||||
document.getElementById('createModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal('createModal');
|
||||
});
|
||||
|
||||
// Enter key on login
|
||||
document.getElementById('loginPwd').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user