|
|
|
|
@@ -1,15 +1,17 @@
|
|
|
|
|
/**
|
|
|
|
|
* admin-relay-panel.ts — Serves the relay admin dashboard HTML at /admin/relay.
|
|
|
|
|
*
|
|
|
|
|
* This is a self-contained vanilla-JS SPA served as inline HTML from Express.
|
|
|
|
|
* Auth gate: on first visit the user is prompted for ADMIN_TOKEN, which is
|
|
|
|
|
* stored in localStorage and sent as Bearer on every API call.
|
|
|
|
|
* This page is registered directly in app.ts at /admin/relay (NOT under /api)
|
|
|
|
|
* so the route is accessible at a clean URL.
|
|
|
|
|
*
|
|
|
|
|
* Auth gate: on first visit the user is prompted for ADMIN_TOKEN, stored in
|
|
|
|
|
* localStorage, sent as Bearer Authorization header on every /api/admin call.
|
|
|
|
|
*
|
|
|
|
|
* Tabs:
|
|
|
|
|
* Queue — Pending events list with Approve / Reject; auto-refreshes every 15s
|
|
|
|
|
* Queue — Pending events list with content preview, Approve/Reject; 15s auto-refresh
|
|
|
|
|
* Accounts — Whitelist table with Revoke; pubkey grant form
|
|
|
|
|
*
|
|
|
|
|
* Stats bar at top: pending, approved today, total accounts.
|
|
|
|
|
* Stats bar: pending, approved today, total accounts, live relay connections.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { Router } from "express";
|
|
|
|
|
@@ -24,8 +26,7 @@ router.get("/admin/relay", (_req, res) => {
|
|
|
|
|
export default router;
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// HTML is defined as a const so the file stays a valid TS module with no imports
|
|
|
|
|
// at runtime and no build step required.
|
|
|
|
|
// Inline HTML — no build step required, served directly by Express.
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
@@ -47,6 +48,7 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
--green: #00d4aa;
|
|
|
|
|
--red: #ff4d6d;
|
|
|
|
|
--yellow: #f7c948;
|
|
|
|
|
--blue: #4da8ff;
|
|
|
|
|
}
|
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
body {
|
|
|
|
|
@@ -60,20 +62,15 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
padding: 32px 20px 60px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Header ── */
|
|
|
|
|
header {
|
|
|
|
|
width: 100%;
|
|
|
|
|
max-width: 900px;
|
|
|
|
|
max-width: 960px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
margin-bottom: 28px;
|
|
|
|
|
}
|
|
|
|
|
.header-title {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 14px;
|
|
|
|
|
}
|
|
|
|
|
.header-title { display: flex; align-items: center; gap: 14px; }
|
|
|
|
|
.header-title h1 {
|
|
|
|
|
font-family: system-ui, sans-serif;
|
|
|
|
|
font-size: 1.4rem;
|
|
|
|
|
@@ -92,11 +89,7 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
letter-spacing: 1px;
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
}
|
|
|
|
|
.header-links {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 14px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
.header-links { display: flex; gap: 14px; align-items: center; }
|
|
|
|
|
.header-links a {
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
@@ -179,7 +172,7 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Main content ── */
|
|
|
|
|
#main { display: none; width: 100%; max-width: 900px; }
|
|
|
|
|
#main { display: none; width: 100%; max-width: 960px; }
|
|
|
|
|
|
|
|
|
|
/* ── Stats bar ── */
|
|
|
|
|
.stats-bar {
|
|
|
|
|
@@ -210,15 +203,14 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
letter-spacing: 1px;
|
|
|
|
|
font-family: system-ui, sans-serif;
|
|
|
|
|
}
|
|
|
|
|
.stat-card.accent .stat-value { color: var(--accent); }
|
|
|
|
|
.stat-card.green .stat-value { color: var(--green); }
|
|
|
|
|
.stat-card.purple .stat-value { color: var(--accent2); }
|
|
|
|
|
.stat-card.yellow .stat-value { color: var(--yellow); }
|
|
|
|
|
.stat-card.green .stat-value { color: var(--green); }
|
|
|
|
|
.stat-card.purple .stat-value { color: var(--accent2); }
|
|
|
|
|
.stat-card.blue .stat-value { color: var(--blue); }
|
|
|
|
|
|
|
|
|
|
/* ── Tabs ── */
|
|
|
|
|
.tab-bar {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0;
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
}
|
|
|
|
|
@@ -263,17 +255,8 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 1px;
|
|
|
|
|
}
|
|
|
|
|
.table-header .refresh-hint {
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
font-family: system-ui, sans-serif;
|
|
|
|
|
}
|
|
|
|
|
table {
|
|
|
|
|
width: 100%;
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
font-family: system-ui, sans-serif;
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
}
|
|
|
|
|
.table-header .hint { font-size: 0.7rem; color: var(--muted); font-family: system-ui, sans-serif; }
|
|
|
|
|
table { width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.82rem; }
|
|
|
|
|
th {
|
|
|
|
|
text-align: left;
|
|
|
|
|
padding: 10px 16px;
|
|
|
|
|
@@ -295,22 +278,9 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
tr:last-child td { border-bottom: none; }
|
|
|
|
|
tr:hover td { background: rgba(255,255,255,0.02); }
|
|
|
|
|
|
|
|
|
|
.pubkey-cell {
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
color: var(--accent2);
|
|
|
|
|
}
|
|
|
|
|
.content-preview {
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
max-width: 220px;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
}
|
|
|
|
|
.timestamp {
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
}
|
|
|
|
|
.mono { font-family: monospace; font-size: 0.78rem; color: var(--accent2); }
|
|
|
|
|
.preview { color: var(--muted); max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
|
.ts { color: var(--muted); font-size: 0.75rem; }
|
|
|
|
|
|
|
|
|
|
/* ── Status pill ── */
|
|
|
|
|
.pill {
|
|
|
|
|
@@ -322,16 +292,16 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
letter-spacing: 0.3px;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
.pill-pending { background: #2a2010; color: var(--yellow); border: 1px solid #443010; }
|
|
|
|
|
.pill-approved { background: #0d2820; color: var(--green); border: 1px solid #0f4030; }
|
|
|
|
|
.pill-auto_approved { background: #0d2820; color: var(--green); border: 1px solid #0f4030; }
|
|
|
|
|
.pill-rejected { background: #2a0f18; color: var(--red); border: 1px solid #441020; }
|
|
|
|
|
.pill-read { background: #1a1a2e; color: var(--accent2); border: 1px solid #2a1a4e; }
|
|
|
|
|
.pill-write { background: #1a2a10; color: var(--green); border: 1px solid #1a4010; }
|
|
|
|
|
.pill-elite { background: #2a1a00; color: var(--accent); border: 1px solid #4a2a00; }
|
|
|
|
|
.pill-none { background: #1a1a1a; color: var(--muted); border: 1px solid #2a2a2a; }
|
|
|
|
|
.pill-pending { background: #2a2010; color: var(--yellow); border: 1px solid #443010; }
|
|
|
|
|
.pill-approved { background: #0d2820; color: var(--green); border: 1px solid #0f4030; }
|
|
|
|
|
.pill-auto_approved{ background: #0d2820; color: var(--green); border: 1px solid #0f4030; }
|
|
|
|
|
.pill-rejected { background: #2a0f18; color: var(--red); border: 1px solid #441020; }
|
|
|
|
|
.pill-read { background: #1a1a2e; color: var(--accent2); border: 1px solid #2a1a4e; }
|
|
|
|
|
.pill-write { background: #1a2a10; color: var(--green); border: 1px solid #1a4010; }
|
|
|
|
|
.pill-elite { background: #2a1a00; color: var(--accent); border: 1px solid #4a2a00; }
|
|
|
|
|
.pill-none { background: #1a1a1a; color: var(--muted); border: 1px solid #2a2a2a; }
|
|
|
|
|
|
|
|
|
|
/* ── Action buttons ── */
|
|
|
|
|
/* ── Buttons ── */
|
|
|
|
|
.btn {
|
|
|
|
|
border: none;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
@@ -345,14 +315,13 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
.btn:hover { opacity: 0.85; transform: translateY(-1px); }
|
|
|
|
|
.btn:disabled { opacity: 0.35; cursor: not-allowed; transform: none; }
|
|
|
|
|
.btn-approve { background: var(--green); color: #000; }
|
|
|
|
|
.btn-reject { background: var(--red); color: #fff; margin-left: 6px; }
|
|
|
|
|
.btn-reject { background: var(--red); color: #fff; margin-left: 6px; }
|
|
|
|
|
.btn-revoke { background: transparent; border: 1px solid var(--red); color: var(--red); }
|
|
|
|
|
|
|
|
|
|
/* ── Grant form ── */
|
|
|
|
|
.grant-form {
|
|
|
|
|
background: var(--surface2);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-top: none;
|
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
@@ -381,32 +350,9 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
}
|
|
|
|
|
.grant-form input:focus, .grant-form select:focus { border-color: var(--accent2); }
|
|
|
|
|
.grant-form select { font-family: system-ui, sans-serif; }
|
|
|
|
|
.btn-grant {
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: #000;
|
|
|
|
|
padding: 8px 18px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.btn-grant { background: var(--accent); color: #000; padding: 8px 18px; flex-shrink: 0; }
|
|
|
|
|
|
|
|
|
|
/* ── Empty / loading state ── */
|
|
|
|
|
.empty-state {
|
|
|
|
|
padding: 36px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
font-family: system-ui, sans-serif;
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
}
|
|
|
|
|
.spinner {
|
|
|
|
|
display: inline-block;
|
|
|
|
|
width: 14px; height: 14px;
|
|
|
|
|
border: 2px solid var(--border);
|
|
|
|
|
border-top-color: var(--accent);
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
animation: spin 0.8s linear infinite;
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
}
|
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
|
.empty-state { padding: 36px; text-align: center; color: var(--muted); font-family: system-ui, sans-serif; font-size: 0.88rem; }
|
|
|
|
|
|
|
|
|
|
/* ── Toast ── */
|
|
|
|
|
#toast {
|
|
|
|
|
@@ -427,10 +373,8 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
max-width: 320px;
|
|
|
|
|
}
|
|
|
|
|
#toast.show { opacity: 1; }
|
|
|
|
|
#toast.ok { border-color: var(--green); color: var(--green); }
|
|
|
|
|
#toast.err { border-color: var(--red); color: var(--red); }
|
|
|
|
|
|
|
|
|
|
.hidden { display: none !important; }
|
|
|
|
|
#toast.ok { border-color: var(--green); color: var(--green); }
|
|
|
|
|
#toast.err { border-color: var(--red); color: var(--red); }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
@@ -438,7 +382,7 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
<!-- ── Auth gate ─────────────────────────────────────────────────────────── -->
|
|
|
|
|
<div id="auth-gate">
|
|
|
|
|
<h2>🔒 Admin Access</h2>
|
|
|
|
|
<p>Enter the relay admin token to access the dashboard. It will be remembered in this browser.</p>
|
|
|
|
|
<p>Enter the <strong>ADMIN_TOKEN</strong> to access the relay dashboard. It will be remembered in this browser.</p>
|
|
|
|
|
<input type="password" id="token-input" placeholder="Admin token…" autocomplete="current-password"/>
|
|
|
|
|
<button class="auth-btn" onclick="submitToken()">Unlock dashboard</button>
|
|
|
|
|
<div id="auth-error">Incorrect token — please try again.</div>
|
|
|
|
|
@@ -452,14 +396,14 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
<h1>⚡ Timmy <span>Relay</span> Admin <span class="badge">OPERATOR</span></h1>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="header-links">
|
|
|
|
|
<a href="/api/ui">← Timmy UI</a>
|
|
|
|
|
<a href="/api/ui">← Timmy UI</a>
|
|
|
|
|
<a href="/tower">Workshop</a>
|
|
|
|
|
<button id="logout-btn" onclick="logout()">Log out</button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<!-- Stats bar -->
|
|
|
|
|
<div class="stats-bar" id="stats-bar">
|
|
|
|
|
<!-- Stats bar: 4 metric cards -->
|
|
|
|
|
<div class="stats-bar">
|
|
|
|
|
<div class="stat-card yellow">
|
|
|
|
|
<div class="stat-value" id="stat-pending">—</div>
|
|
|
|
|
<div class="stat-label">Pending review</div>
|
|
|
|
|
@@ -472,9 +416,9 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
<div class="stat-value" id="stat-accounts">—</div>
|
|
|
|
|
<div class="stat-label">Accounts</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-card accent">
|
|
|
|
|
<div class="stat-value" id="stat-total-queue">—</div>
|
|
|
|
|
<div class="stat-label">All-time queue</div>
|
|
|
|
|
<div class="stat-card blue">
|
|
|
|
|
<div class="stat-value" id="stat-connections">—</div>
|
|
|
|
|
<div class="stat-label">Relay connections</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@@ -489,7 +433,7 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
<div class="table-header">
|
|
|
|
|
<h3>Pending events</h3>
|
|
|
|
|
<span class="refresh-hint" id="queue-refresh-hint">auto-refreshes every 15s</span>
|
|
|
|
|
<span class="hint">auto-refreshes every 15s</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="queue-body"></div>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -500,10 +444,9 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
<div class="table-header">
|
|
|
|
|
<h3>Relay accounts</h3>
|
|
|
|
|
<span class="refresh-hint">whitelist</span>
|
|
|
|
|
<span class="hint">whitelist</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="accounts-body"></div>
|
|
|
|
|
<!-- Grant form -->
|
|
|
|
|
<div class="grant-form">
|
|
|
|
|
<label>
|
|
|
|
|
Pubkey (64-char hex)
|
|
|
|
|
@@ -533,14 +476,13 @@ const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
|
|
|
|
const BASE = window.location.origin;
|
|
|
|
|
const LS_KEY = 'relay_admin_token';
|
|
|
|
|
let adminToken = '';
|
|
|
|
|
let queueTimer = null;
|
|
|
|
|
let refreshTimer = null;
|
|
|
|
|
|
|
|
|
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function submitToken() {
|
|
|
|
|
const val = document.getElementById('token-input').value.trim();
|
|
|
|
|
if (!val) return;
|
|
|
|
|
// Verify by calling a protected endpoint
|
|
|
|
|
const r = await fetch(BASE + '/api/admin/relay/stats', {
|
|
|
|
|
headers: { Authorization: 'Bearer ' + val }
|
|
|
|
|
});
|
|
|
|
|
@@ -550,7 +492,7 @@ async function submitToken() {
|
|
|
|
|
}
|
|
|
|
|
adminToken = val;
|
|
|
|
|
localStorage.setItem(LS_KEY, val);
|
|
|
|
|
showMain();
|
|
|
|
|
showMain(await r.json());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('token-input').addEventListener('keydown', e => {
|
|
|
|
|
@@ -560,27 +502,28 @@ document.getElementById('token-input').addEventListener('keydown', e => {
|
|
|
|
|
function logout() {
|
|
|
|
|
localStorage.removeItem(LS_KEY);
|
|
|
|
|
adminToken = '';
|
|
|
|
|
clearInterval(queueTimer);
|
|
|
|
|
clearInterval(refreshTimer);
|
|
|
|
|
document.getElementById('main').style.display = 'none';
|
|
|
|
|
document.getElementById('auth-gate').style.display = 'block';
|
|
|
|
|
document.getElementById('token-input').value = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function showMain() {
|
|
|
|
|
async function showMain(initialStats) {
|
|
|
|
|
document.getElementById('auth-gate').style.display = 'none';
|
|
|
|
|
document.getElementById('main').style.display = 'block';
|
|
|
|
|
await Promise.all([loadStats(), loadQueue(), loadAccounts()]);
|
|
|
|
|
queueTimer = setInterval(async () => {
|
|
|
|
|
if (initialStats) renderStats(initialStats);
|
|
|
|
|
await Promise.all([loadStats(), loadQueue()]);
|
|
|
|
|
refreshTimer = setInterval(async () => {
|
|
|
|
|
await Promise.all([loadStats(), loadQueue()]);
|
|
|
|
|
}, 15000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── API helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function api(path, opts = {}) {
|
|
|
|
|
const headers = { Authorization: 'Bearer ' + adminToken, 'Content-Type': 'application/json', ...(opts.headers || {}) };
|
|
|
|
|
const r = await fetch(BASE + '/api' + path, { ...opts, headers });
|
|
|
|
|
return r;
|
|
|
|
|
async function api(path, opts) {
|
|
|
|
|
opts = opts || {};
|
|
|
|
|
const headers = Object.assign({ Authorization: 'Bearer ' + adminToken, 'Content-Type': 'application/json' }, opts.headers || {});
|
|
|
|
|
return fetch(BASE + '/api' + path, Object.assign({}, opts, { headers }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Stats ────────────────────────────────────────────────────────────────────
|
|
|
|
|
@@ -589,76 +532,72 @@ async function loadStats() {
|
|
|
|
|
try {
|
|
|
|
|
const r = await api('/admin/relay/stats');
|
|
|
|
|
if (!r.ok) return;
|
|
|
|
|
const d = await r.json();
|
|
|
|
|
document.getElementById('stat-pending').textContent = d.pending;
|
|
|
|
|
document.getElementById('stat-approved-today').textContent = d.approvedToday;
|
|
|
|
|
document.getElementById('stat-accounts').textContent = d.totalAccounts;
|
|
|
|
|
const total = (d.pending || 0) + (d.approved || 0) + (d.autoApproved || 0) + (d.rejected || 0);
|
|
|
|
|
document.getElementById('stat-total-queue').textContent = total;
|
|
|
|
|
renderStats(await r.json());
|
|
|
|
|
} catch(e) { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderStats(d) {
|
|
|
|
|
document.getElementById('stat-pending').textContent = d.pending != null ? d.pending : '—';
|
|
|
|
|
document.getElementById('stat-approved-today').textContent = d.approvedToday != null ? d.approvedToday : '—';
|
|
|
|
|
document.getElementById('stat-accounts').textContent = d.totalAccounts != null ? d.totalAccounts : '—';
|
|
|
|
|
document.getElementById('stat-connections').textContent = d.liveConnections != null ? d.liveConnections : 'n/a';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Queue tab ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function loadQueue() {
|
|
|
|
|
const body = document.getElementById('queue-body');
|
|
|
|
|
try {
|
|
|
|
|
const r = await api('/admin/relay/queue');
|
|
|
|
|
const r = await api('/admin/relay/queue?status=pending');
|
|
|
|
|
if (!r.ok) { body.innerHTML = '<div class="empty-state">Failed to load queue.</div>'; return; }
|
|
|
|
|
const d = await r.json();
|
|
|
|
|
if (!d.events.length) {
|
|
|
|
|
body.innerHTML = '<div class="empty-state">No events in queue.</div>';
|
|
|
|
|
if (!d.events || !d.events.length) {
|
|
|
|
|
body.innerHTML = '<div class="empty-state">No pending events.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
body.innerHTML = '<table>' +
|
|
|
|
|
'<thead><tr>' +
|
|
|
|
|
'<th>Event ID</th>' +
|
|
|
|
|
'<th>Pubkey</th>' +
|
|
|
|
|
'<th>Kind</th>' +
|
|
|
|
|
'<th>Status</th>' +
|
|
|
|
|
'<th>Queued</th>' +
|
|
|
|
|
'<th>Actions</th>' +
|
|
|
|
|
'</tr></thead>' +
|
|
|
|
|
'<tbody>' + d.events.map(ev => renderQueueRow(ev)).join('') + '</tbody>' +
|
|
|
|
|
'</table>';
|
|
|
|
|
body.innerHTML = '<table><thead><tr>' +
|
|
|
|
|
'<th>Event ID</th><th>Pubkey</th><th>Kind</th><th>Content preview</th><th>Status</th><th>Queued</th><th>Actions</th>' +
|
|
|
|
|
'</tr></thead><tbody>' + d.events.map(renderQueueRow).join('') + '</tbody></table>';
|
|
|
|
|
} catch(e) {
|
|
|
|
|
body.innerHTML = '<div class="empty-state">Network error.</div>';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderQueueRow(ev) {
|
|
|
|
|
const id8 = ev.eventId ? ev.eventId.slice(0,8) : '—';
|
|
|
|
|
const pk8 = ev.pubkey ? ev.pubkey.slice(0,12) + '…' : '—';
|
|
|
|
|
const ts = ev.createdAt ? new Date(ev.createdAt).toLocaleString() : '—';
|
|
|
|
|
const isPending = ev.status === 'pending';
|
|
|
|
|
const approveBtn = isPending
|
|
|
|
|
? '<button class="btn btn-approve" onclick="queueApprove(\\'' + ev.eventId + '\\',this)">Approve</button>'
|
|
|
|
|
: '';
|
|
|
|
|
const rejectBtn = isPending
|
|
|
|
|
? '<button class="btn btn-reject" onclick="queueReject(\\'' + ev.eventId + '\\',this)">Reject</button>'
|
|
|
|
|
: '';
|
|
|
|
|
var id8 = ev.eventId ? ev.eventId.slice(0,8) + '…' : '—';
|
|
|
|
|
var pk12 = ev.pubkey ? ev.pubkey.slice(0,12) + '…' : '—';
|
|
|
|
|
var preview = ev.contentPreview ? ev.contentPreview : '(empty)';
|
|
|
|
|
var ts = ev.createdAt ? new Date(ev.createdAt).toLocaleString() : '—';
|
|
|
|
|
var pill = '<span class="pill pill-' + (ev.status||'none') + '">' + (ev.status||'—') + '</span>';
|
|
|
|
|
var approve = '<button class="btn btn-approve" onclick="queueApprove(\'' + ev.eventId + '\',this)">Approve</button>';
|
|
|
|
|
var reject = '<button class="btn btn-reject" onclick="queueReject(\'' + ev.eventId + '\',this)">Reject</button>';
|
|
|
|
|
return '<tr>' +
|
|
|
|
|
'<td class="pubkey-cell">' + id8 + '…</td>' +
|
|
|
|
|
'<td class="pubkey-cell">' + pk8 + '</td>' +
|
|
|
|
|
'<td>' + (ev.kind ?? '—') + '</td>' +
|
|
|
|
|
'<td><span class="pill pill-' + (ev.status || 'none') + '">' + (ev.status || '—') + '</span></td>' +
|
|
|
|
|
'<td class="timestamp">' + ts + '</td>' +
|
|
|
|
|
'<td>' + approveBtn + rejectBtn + '</td>' +
|
|
|
|
|
'<td class="mono">' + id8 + '</td>' +
|
|
|
|
|
'<td class="mono">' + pk12 + '</td>' +
|
|
|
|
|
'<td>' + (ev.kind != null ? ev.kind : '—') + '</td>' +
|
|
|
|
|
'<td class="preview" title="' + preview.replace(/"/g,'"') + '">' + preview + '</td>' +
|
|
|
|
|
'<td>' + pill + '</td>' +
|
|
|
|
|
'<td class="ts">' + ts + '</td>' +
|
|
|
|
|
'<td>' + approve + reject + '</td>' +
|
|
|
|
|
'</tr>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function queueApprove(eventId, btn) {
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
const r = await api('/admin/relay/queue/' + eventId + '/approve', { method: 'POST', body: JSON.stringify({ reason: 'admin approval' }) });
|
|
|
|
|
if (r.ok) { toast('Event approved', 'ok'); await loadQueue(); await loadStats(); }
|
|
|
|
|
else { const d = await r.json(); toast(d.error || 'Approve failed', 'err'); btn.disabled = false; }
|
|
|
|
|
try {
|
|
|
|
|
const r = await api('/admin/relay/queue/' + eventId + '/approve', { method: 'POST', body: JSON.stringify({ reason: 'admin approval' }) });
|
|
|
|
|
if (r.ok) { toast('Event approved', 'ok'); loadQueue(); loadStats(); }
|
|
|
|
|
else { const d = await r.json(); toast(d.error || 'Approve failed', 'err'); btn.disabled = false; }
|
|
|
|
|
} catch(e) { toast('Network error', 'err'); btn.disabled = false; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function queueReject(eventId, btn) {
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
const r = await api('/admin/relay/queue/' + eventId + '/reject', { method: 'POST', body: JSON.stringify({ reason: 'admin rejection' }) });
|
|
|
|
|
if (r.ok) { toast('Event rejected', 'ok'); await loadQueue(); await loadStats(); }
|
|
|
|
|
else { const d = await r.json(); toast(d.error || 'Reject failed', 'err'); btn.disabled = false; }
|
|
|
|
|
try {
|
|
|
|
|
const r = await api('/admin/relay/queue/' + eventId + '/reject', { method: 'POST', body: JSON.stringify({ reason: 'admin rejection' }) });
|
|
|
|
|
if (r.ok) { toast('Event rejected', 'ok'); loadQueue(); loadStats(); }
|
|
|
|
|
else { const d = await r.json(); toast(d.error || 'Reject failed', 'err'); btn.disabled = false; }
|
|
|
|
|
} catch(e) { toast('Network error', 'err'); btn.disabled = false; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Accounts tab ─────────────────────────────────────────────────────────────
|
|
|
|
|
@@ -669,112 +608,98 @@ async function loadAccounts() {
|
|
|
|
|
const r = await api('/admin/relay/accounts');
|
|
|
|
|
if (!r.ok) { body.innerHTML = '<div class="empty-state">Failed to load accounts.</div>'; return; }
|
|
|
|
|
const d = await r.json();
|
|
|
|
|
if (!d.accounts.length) {
|
|
|
|
|
if (!d.accounts || !d.accounts.length) {
|
|
|
|
|
body.innerHTML = '<div class="empty-state">No accounts whitelisted.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
body.innerHTML = '<table>' +
|
|
|
|
|
'<thead><tr>' +
|
|
|
|
|
'<th>Pubkey</th>' +
|
|
|
|
|
'<th>Access</th>' +
|
|
|
|
|
'<th>Trust tier</th>' +
|
|
|
|
|
'<th>Granted by</th>' +
|
|
|
|
|
'<th>Notes</th>' +
|
|
|
|
|
'<th>Granted</th>' +
|
|
|
|
|
'<th>Actions</th>' +
|
|
|
|
|
'</tr></thead>' +
|
|
|
|
|
'<tbody>' + d.accounts.map(ac => renderAccountRow(ac)).join('') + '</tbody>' +
|
|
|
|
|
'</table>';
|
|
|
|
|
body.innerHTML = '<table><thead><tr>' +
|
|
|
|
|
'<th>Pubkey</th><th>Access</th><th>Trust tier</th><th>Granted by</th><th>Notes</th><th>Date</th><th>Action</th>' +
|
|
|
|
|
'</tr></thead><tbody>' + d.accounts.map(renderAccountRow).join('') + '</tbody></table>';
|
|
|
|
|
} catch(e) {
|
|
|
|
|
body.innerHTML = '<div class="empty-state">Network error.</div>';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderAccountRow(ac) {
|
|
|
|
|
const pk = ac.pubkey || '';
|
|
|
|
|
const pk12 = pk.slice(0,12) + (pk.length > 12 ? '…' : '');
|
|
|
|
|
const ts = ac.grantedAt ? new Date(ac.grantedAt).toLocaleDateString() : '—';
|
|
|
|
|
const level = ac.accessLevel || 'none';
|
|
|
|
|
const tier = ac.trustTier || '—';
|
|
|
|
|
const grantedBy = ac.grantedBy || '—';
|
|
|
|
|
const notes = ac.notes ? (ac.notes.length > 30 ? ac.notes.slice(0,30) + '…' : ac.notes) : '';
|
|
|
|
|
const isRevoked = ac.revokedAt != null;
|
|
|
|
|
const revokeBtn = !isRevoked
|
|
|
|
|
? '<button class="btn btn-revoke" onclick="revokeAccount(\\'' + pk + '\\',this)">Revoke</button>'
|
|
|
|
|
: '<span style="color:var(--muted);font-size:0.75rem;">revoked</span>';
|
|
|
|
|
var pk = ac.pubkey || '';
|
|
|
|
|
var pk12 = pk.slice(0,12) + (pk.length > 12 ? '…' : '');
|
|
|
|
|
var ts = ac.grantedAt ? new Date(ac.grantedAt).toLocaleDateString() : '—';
|
|
|
|
|
var level = ac.accessLevel || 'none';
|
|
|
|
|
var tier = ac.trustTier || '—';
|
|
|
|
|
var notes = (ac.notes || '').slice(0,30) + ((ac.notes||'').length > 30 ? '…' : '');
|
|
|
|
|
var isRevoked = ac.revokedAt != null;
|
|
|
|
|
var action = isRevoked
|
|
|
|
|
? '<span style="color:var(--muted);font-size:0.75rem;">revoked</span>'
|
|
|
|
|
: '<button class="btn btn-revoke" onclick="revokeAccount(\'' + pk + '\',this)">Revoke</button>';
|
|
|
|
|
return '<tr>' +
|
|
|
|
|
'<td class="pubkey-cell" title="' + pk + '">' + pk12 + '</td>' +
|
|
|
|
|
'<td class="mono" title="' + pk + '">' + pk12 + '</td>' +
|
|
|
|
|
'<td><span class="pill pill-' + level + '">' + level + '</span></td>' +
|
|
|
|
|
'<td>' + tier + '</td>' +
|
|
|
|
|
'<td style="color:var(--muted);">' + grantedBy + '</td>' +
|
|
|
|
|
'<td style="color:var(--muted);">' + (ac.grantedBy||'—') + '</td>' +
|
|
|
|
|
'<td style="color:var(--muted);font-size:0.78rem;">' + notes + '</td>' +
|
|
|
|
|
'<td class="timestamp">' + ts + '</td>' +
|
|
|
|
|
'<td>' + revokeBtn + '</td>' +
|
|
|
|
|
'<td class="ts">' + ts + '</td>' +
|
|
|
|
|
'<td>' + action + '</td>' +
|
|
|
|
|
'</tr>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function revokeAccount(pubkey, btn) {
|
|
|
|
|
if (!confirm('Revoke access for ' + pubkey.slice(0,12) + '…?')) return;
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
const r = await api('/admin/relay/accounts/' + pubkey + '/revoke', { method: 'POST', body: JSON.stringify({ reason: 'admin revoke' }) });
|
|
|
|
|
if (r.ok) { toast('Access revoked', 'ok'); await loadAccounts(); await loadStats(); }
|
|
|
|
|
else { const d = await r.json(); toast(d.error || 'Revoke failed', 'err'); btn.disabled = false; }
|
|
|
|
|
try {
|
|
|
|
|
const r = await api('/admin/relay/accounts/' + pubkey + '/revoke', { method: 'POST', body: JSON.stringify({ reason: 'admin revoke' }) });
|
|
|
|
|
if (r.ok) { toast('Access revoked', 'ok'); loadAccounts(); loadStats(); }
|
|
|
|
|
else { const d = await r.json(); toast(d.error || 'Revoke failed', 'err'); btn.disabled = false; }
|
|
|
|
|
} catch(e) { toast('Network error', 'err'); btn.disabled = false; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function grantAccount() {
|
|
|
|
|
const pubkey = document.getElementById('grant-pubkey').value.trim();
|
|
|
|
|
const level = document.getElementById('grant-level').value;
|
|
|
|
|
const notes = document.getElementById('grant-notes').value.trim();
|
|
|
|
|
|
|
|
|
|
if (!/^[0-9a-f]{64}$/.test(pubkey)) {
|
|
|
|
|
toast('Pubkey must be 64 lowercase hex chars', 'err');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const r = await api('/admin/relay/accounts/' + pubkey + '/grant', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ level, notes: notes || undefined })
|
|
|
|
|
});
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
toast('Access granted: ' + pubkey.slice(0,8) + '…', 'ok');
|
|
|
|
|
document.getElementById('grant-pubkey').value = '';
|
|
|
|
|
document.getElementById('grant-notes').value = '';
|
|
|
|
|
await loadAccounts();
|
|
|
|
|
await loadStats();
|
|
|
|
|
} else {
|
|
|
|
|
const d = await r.json();
|
|
|
|
|
toast(d.error || 'Grant failed', 'err');
|
|
|
|
|
}
|
|
|
|
|
var pubkey = document.getElementById('grant-pubkey').value.trim();
|
|
|
|
|
var level = document.getElementById('grant-level').value;
|
|
|
|
|
var notes = document.getElementById('grant-notes').value.trim();
|
|
|
|
|
if (!/^[0-9a-f]{64}$/.test(pubkey)) { toast('Pubkey must be 64 lowercase hex chars', 'err'); return; }
|
|
|
|
|
try {
|
|
|
|
|
const r = await api('/admin/relay/accounts/' + pubkey + '/grant', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ level, notes: notes || undefined })
|
|
|
|
|
});
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
toast('Access granted: ' + pubkey.slice(0,8) + '…', 'ok');
|
|
|
|
|
document.getElementById('grant-pubkey').value = '';
|
|
|
|
|
document.getElementById('grant-notes').value = '';
|
|
|
|
|
loadAccounts(); loadStats();
|
|
|
|
|
} else { const d = await r.json(); toast(d.error || 'Grant failed', 'err'); }
|
|
|
|
|
} catch(e) { toast('Network error', 'err'); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function switchTab(name, btn) {
|
|
|
|
|
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
|
|
|
document.querySelectorAll('.tab-pane').forEach(function(p){ p.classList.remove('active'); });
|
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(function(b){ b.classList.remove('active'); });
|
|
|
|
|
document.getElementById('tab-' + name).classList.add('active');
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
if (name === 'accounts') loadAccounts();
|
|
|
|
|
if (name === 'queue') loadQueue();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
let toastTimer = null;
|
|
|
|
|
function toast(msg, type = 'ok') {
|
|
|
|
|
const el = document.getElementById('toast');
|
|
|
|
|
var toastTimer = null;
|
|
|
|
|
function toast(msg, type) {
|
|
|
|
|
var el = document.getElementById('toast');
|
|
|
|
|
el.textContent = msg;
|
|
|
|
|
el.className = 'show ' + type;
|
|
|
|
|
el.className = 'show ' + (type || 'ok');
|
|
|
|
|
clearTimeout(toastTimer);
|
|
|
|
|
toastTimer = setTimeout(() => { el.className = ''; }, 3000);
|
|
|
|
|
toastTimer = setTimeout(function(){ el.className = ''; }, 3000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Boot ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
(function boot() {
|
|
|
|
|
const saved = localStorage.getItem(LS_KEY);
|
|
|
|
|
var saved = localStorage.getItem(LS_KEY);
|
|
|
|
|
if (saved) {
|
|
|
|
|
adminToken = saved;
|
|
|
|
|
showMain();
|
|
|
|
|
showMain(null);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
|