diff --git a/artifacts/api-server/src/routes/admin-relay-panel.ts b/artifacts/api-server/src/routes/admin-relay-panel.ts new file mode 100644 index 0000000..3205eda --- /dev/null +++ b/artifacts/api-server/src/routes/admin-relay-panel.ts @@ -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 = ` + + + + +Relay Admin — Timmy Tower World + + + + + +
+

🔒 Admin Access

+

Enter the relay admin token to access the dashboard. It will be remembered in this browser.

+ + +
Incorrect token — please try again.
+
+ + +
+ +
+
+

⚡ Timmy Relay Admin OPERATOR

+
+ +
+ + +
+
+
+
Pending review
+
+
+
+
Approved today
+
+
+
+
Accounts
+
+
+
+
All-time queue
+
+
+ + +
+ + +
+ + +
+
+
+

Pending events

+ auto-refreshes every 15s +
+
+
+
+ + +
+
+
+

Relay accounts

+ whitelist +
+
+ +
+ + + + +
+
+
+ +
+ +
+ + + +`; diff --git a/artifacts/api-server/src/routes/admin-relay.ts b/artifacts/api-server/src/routes/admin-relay.ts index ff73892..4e33775 100644 --- a/artifacts/api-server/src/routes/admin-relay.ts +++ b/artifacts/api-server/src/routes/admin-relay.ts @@ -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`count(*)::int` }) + .from(relayEventQueue) + .groupBy(relayEventQueue.status), + db.select({ count: sql`count(*)::int` }).from(relayAccounts), + db + .select({ count: sql`count(*)::int` }) + .from(relayEventQueue) + .where( + gte(relayEventQueue.decidedAt, todayStart), + ), + ]); + + const statusMap: Record = {}; + 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) => { diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index ed68a82..0c19192 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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);