task/33: Relay admin panel at /api/admin/relay
## What was built
A full operator dashboard for the Timmy relay, served as server-side HTML
from Express at GET /api/admin/relay — no build step, no separate frontend.
Follows the existing ui.ts pattern with vanilla JS.
## New API endpoint
GET /api/admin/relay/stats (added to admin-relay.ts):
Returns { pending, approved, autoApproved, rejected, approvedToday, totalAccounts }
approvedToday counts events with decidedAt >= UTC midnight today.
Uses Drizzle groupBy on relayEventQueue.status + count(*) aggregate.
Protected by requireAdmin (same ADMIN_SECRET Bearer auth as other admin routes).
## Admin panel (admin-relay-panel.ts → /api/admin/relay)
No auth requirement on the page GET itself — auth happens client-side via JS.
Auth gate:
On first visit, user is prompted for ADMIN_TOKEN (password input).
Token verified against GET /api/admin/relay/stats (401 = wrong token).
Token stored in localStorage ('relay_admin_token'); loaded on boot.
Logout clears localStorage and stops the 15s refresh timer.
Token sent as Bearer Authorization header on every API call.
Stats bar (4 metric cards):
Pending review (yellow), Approved today (green),
Accounts (purple), All-time queue (orange/accent).
Queue tab:
Fetches GET /api/admin/relay/queue, renders all events in a table.
Columns: Event ID (8-char), Pubkey (12-char+ellipsis), Kind, Status pill,
Queued timestamp, Approve/Reject action buttons (pending rows only).
Auto-refreshes every 15 seconds alongside stats.
Approve/Reject call POST /api/admin/relay/queue/:id/approve|reject.
Accounts tab:
Fetches GET /api/admin/relay/accounts, renders whitelist table.
Columns: Pubkey, Access level pill, Trust tier, Granted by, Notes, Date, Revoke.
Revoke button calls POST /api/admin/relay/accounts/:pubkey/revoke (with confirm).
Grant form at the bottom: pubkey input (64-char hex validation), access level
select, optional notes, calls POST /api/admin/relay/accounts/:pubkey/grant.
Pill styling: pending=yellow, approved/auto_approved=green, rejected=red,
read=purple, write=green, elite=orange, none=grey.
Navigation links: ← Timmy UI, Workshop, Log out.
## Route registration
import adminRelayPanelRouter added to routes/index.ts; router.use() registered
between adminRelayQueueRouter and demoRouter.
## TypeScript: 0 errors. Smoke tests:
- GET /api/admin/relay → 200 HTML with correct <title> ✓
- GET /api/admin/relay/stats (localhost) → 200 with all 6 fields ✓
- Auth gate renders correctly in browser ✓
This commit is contained in:
782
artifacts/api-server/src/routes/admin-relay-panel.ts
Normal file
782
artifacts/api-server/src/routes/admin-relay-panel.ts
Normal file
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Tabs:
|
||||
* Queue — Pending events list with Approve / Reject; auto-refreshes every 15s
|
||||
* Accounts — Whitelist table with Revoke; pubkey grant form
|
||||
*
|
||||
* Stats bar at top: pending, approved today, total accounts.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/admin/relay", (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.send(ADMIN_PANEL_HTML);
|
||||
});
|
||||
|
||||
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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ADMIN_PANEL_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Relay Admin — Timmy Tower World</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--surface: #13131a;
|
||||
--surface2: #1a1a24;
|
||||
--border: #2a2a3a;
|
||||
--accent: #f7931a;
|
||||
--accent2: #7b61ff;
|
||||
--text: #e8e8f0;
|
||||
--muted: #6b6b80;
|
||||
--green: #00d4aa;
|
||||
--red: #ff4d6d;
|
||||
--yellow: #f7c948;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.header-title h1 {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.header-title h1 span { color: var(--accent); }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid var(--accent2);
|
||||
color: var(--accent2);
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 1px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.header-links {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.header-links a {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
font-family: system-ui, sans-serif;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.header-links a:hover { color: var(--accent); }
|
||||
#logout-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
font-family: system-ui, sans-serif;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
#logout-btn:hover { border-color: var(--red); color: var(--red); }
|
||||
|
||||
/* ── Auth gate ── */
|
||||
#auth-gate {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 36px;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
text-align: center;
|
||||
margin-top: 80px;
|
||||
}
|
||||
#auth-gate h2 {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#auth-gate p {
|
||||
color: var(--muted);
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#auth-gate input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: monospace;
|
||||
font-size: 0.95rem;
|
||||
padding: 12px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
#auth-gate input:focus { border-color: var(--accent2); }
|
||||
#auth-gate .auth-btn {
|
||||
width: 100%;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
#auth-gate .auth-btn:hover { opacity: 0.88; }
|
||||
#auth-error {
|
||||
color: var(--red);
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Main content ── */
|
||||
#main { display: none; width: 100%; max-width: 900px; }
|
||||
|
||||
/* ── Stats bar ── */
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-card .stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
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); }
|
||||
|
||||
/* ── Tabs ── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted);
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
padding: 10px 22px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
|
||||
/* ── Table ── */
|
||||
.table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table-header h3 {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
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;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 16px;
|
||||
background: var(--surface2);
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Status pill ── */
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
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; }
|
||||
|
||||
/* ── Action buttons ── */
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: system-ui, sans-serif;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.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-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;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.grant-form label {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
.grant-form input, .grant-form select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── 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); } }
|
||||
|
||||
/* ── Toast ── */
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
right: 28px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 18px;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 99;
|
||||
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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- ── Main panel ─────────────────────────────────────────────────────────── -->
|
||||
<div id="main">
|
||||
|
||||
<header>
|
||||
<div class="header-title">
|
||||
<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="/tower">Workshop</a>
|
||||
<button id="logout-btn" onclick="logout()">Log out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="stats-bar" id="stats-bar">
|
||||
<div class="stat-card yellow">
|
||||
<div class="stat-value" id="stat-pending">—</div>
|
||||
<div class="stat-label">Pending review</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="stat-value" id="stat-approved-today">—</div>
|
||||
<div class="stat-label">Approved today</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" onclick="switchTab('queue',this)">Moderation Queue</button>
|
||||
<button class="tab-btn" onclick="switchTab('accounts',this)">Accounts</button>
|
||||
</div>
|
||||
|
||||
<!-- Queue tab -->
|
||||
<div id="tab-queue" class="tab-pane active">
|
||||
<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>
|
||||
</div>
|
||||
<div id="queue-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accounts tab -->
|
||||
<div id="tab-accounts" class="tab-pane">
|
||||
<div class="table-wrap">
|
||||
<div class="table-header">
|
||||
<h3>Relay accounts</h3>
|
||||
<span class="refresh-hint">whitelist</span>
|
||||
</div>
|
||||
<div id="accounts-body"></div>
|
||||
<!-- Grant form -->
|
||||
<div class="grant-form">
|
||||
<label>
|
||||
Pubkey (64-char hex)
|
||||
<input id="grant-pubkey" type="text" placeholder="a1b2c3…" maxlength="64" spellcheck="false"/>
|
||||
</label>
|
||||
<label style="flex:0 1 auto;">
|
||||
Access
|
||||
<select id="grant-level">
|
||||
<option value="write">write</option>
|
||||
<option value="read">read</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="flex:0 1 160px;">
|
||||
Notes
|
||||
<input id="grant-notes" type="text" placeholder="optional" style="font-family:system-ui,sans-serif;"/>
|
||||
</label>
|
||||
<button class="btn btn-grant btn" onclick="grantAccount()">Grant access</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const BASE = window.location.origin;
|
||||
const LS_KEY = 'relay_admin_token';
|
||||
let adminToken = '';
|
||||
let queueTimer = 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 }
|
||||
});
|
||||
if (r.status === 401) {
|
||||
document.getElementById('auth-error').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
adminToken = val;
|
||||
localStorage.setItem(LS_KEY, val);
|
||||
showMain();
|
||||
}
|
||||
|
||||
document.getElementById('token-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') submitToken();
|
||||
});
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
adminToken = '';
|
||||
clearInterval(queueTimer);
|
||||
document.getElementById('main').style.display = 'none';
|
||||
document.getElementById('auth-gate').style.display = 'block';
|
||||
document.getElementById('token-input').value = '';
|
||||
}
|
||||
|
||||
async function showMain() {
|
||||
document.getElementById('auth-gate').style.display = 'none';
|
||||
document.getElementById('main').style.display = 'block';
|
||||
await Promise.all([loadStats(), loadQueue(), loadAccounts()]);
|
||||
queueTimer = 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;
|
||||
}
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Queue tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadQueue() {
|
||||
const body = document.getElementById('queue-body');
|
||||
try {
|
||||
const r = await api('/admin/relay/queue');
|
||||
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>';
|
||||
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>';
|
||||
} 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>'
|
||||
: '';
|
||||
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>' +
|
||||
'</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; }
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
// ── Accounts tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAccounts() {
|
||||
const body = document.getElementById('accounts-body');
|
||||
try {
|
||||
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) {
|
||||
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>';
|
||||
} 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>';
|
||||
return '<tr>' +
|
||||
'<td class="pubkey-cell" 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);font-size:0.78rem;">' + notes + '</td>' +
|
||||
'<td class="timestamp">' + ts + '</td>' +
|
||||
'<td>' + revokeBtn + '</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; }
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.getElementById('tab-' + name).classList.add('active');
|
||||
btn.classList.add('active');
|
||||
if (name === 'accounts') loadAccounts();
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg, type = 'ok') {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'show ' + type;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { el.className = ''; }, 3000);
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
(function boot() {
|
||||
const saved = localStorage.getItem(LS_KEY);
|
||||
if (saved) {
|
||||
adminToken = saved;
|
||||
showMain();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* admin-relay.ts — Admin endpoints for the relay account whitelist.
|
||||
* admin-relay.ts — Admin endpoints for the relay account whitelist + stats.
|
||||
*
|
||||
* All routes are protected by ADMIN_SECRET env var (Bearer token).
|
||||
* If ADMIN_SECRET is not set, the routes reject all requests in production
|
||||
* and accept only from localhost in development.
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/admin/relay/accounts — list all relay accounts
|
||||
* GET /api/admin/relay/stats — event counts + account total
|
||||
* GET /api/admin/relay/accounts — list all relay accounts
|
||||
* POST /api/admin/relay/accounts/:pubkey/grant — grant access to a pubkey
|
||||
* POST /api/admin/relay/accounts/:pubkey/revoke — revoke access from a pubkey
|
||||
*/
|
||||
@@ -14,7 +15,8 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from "express";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
import { relayAccountService } from "../lib/relay-accounts.js";
|
||||
import { RELAY_ACCESS_LEVELS, type RelayAccessLevel } from "@workspace/db";
|
||||
import { RELAY_ACCESS_LEVELS, type RelayAccessLevel, db, relayEventQueue, relayAccounts } from "@workspace/db";
|
||||
import { eq, gte, sql } from "drizzle-orm";
|
||||
|
||||
const logger = makeLogger("admin-relay");
|
||||
const router = Router();
|
||||
@@ -55,6 +57,42 @@ function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
next();
|
||||
}
|
||||
|
||||
// ── GET /admin/relay/stats ────────────────────────────────────────────────────
|
||||
// Returns event counts by status for today + all-time pending, plus account total.
|
||||
|
||||
router.get("/admin/relay/stats", requireAdmin, async (_req: Request, res: Response) => {
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const [queueCounts, accountCount, approvedToday] = await Promise.all([
|
||||
db
|
||||
.select({ status: relayEventQueue.status, count: sql<number>`count(*)::int` })
|
||||
.from(relayEventQueue)
|
||||
.groupBy(relayEventQueue.status),
|
||||
db.select({ count: sql<number>`count(*)::int` }).from(relayAccounts),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(relayEventQueue)
|
||||
.where(
|
||||
gte(relayEventQueue.decidedAt, todayStart),
|
||||
),
|
||||
]);
|
||||
|
||||
const statusMap: Record<string, number> = {};
|
||||
for (const row of queueCounts) {
|
||||
statusMap[row.status] = row.count;
|
||||
}
|
||||
|
||||
res.json({
|
||||
pending: statusMap["pending"] ?? 0,
|
||||
approved: statusMap["approved"] ?? 0,
|
||||
autoApproved: statusMap["auto_approved"] ?? 0,
|
||||
rejected: statusMap["rejected"] ?? 0,
|
||||
approvedToday: approvedToday[0]?.count ?? 0,
|
||||
totalAccounts: accountCount[0]?.count ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /admin/relay/accounts ─────────────────────────────────────────────────
|
||||
|
||||
router.get("/admin/relay/accounts", requireAdmin, async (_req: Request, res: Response) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import estimateRouter from "./estimate.js";
|
||||
import relayRouter from "./relay.js";
|
||||
import adminRelayRouter from "./admin-relay.js";
|
||||
import adminRelayQueueRouter from "./admin-relay-queue.js";
|
||||
import adminRelayPanelRouter from "./admin-relay-panel.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -28,6 +29,7 @@ router.use(identityRouter);
|
||||
router.use(relayRouter);
|
||||
router.use(adminRelayRouter);
|
||||
router.use(adminRelayQueueRouter);
|
||||
router.use(adminRelayPanelRouter);
|
||||
router.use(demoRouter);
|
||||
router.use(testkitRouter);
|
||||
router.use(uiRouter);
|
||||
|
||||
Reference in New Issue
Block a user