Proper admin panel at /admin

Replaces the table tucked into Settings with a real instance dashboard,
gated server-side to ADMIN_USERS (frontend nav entry appears for admins only).

Backend (/api/admin/*, admin-token required):
- GET overview: uptime, RSS/heap, data-dir bytes, account count vs MAX_USERS,
  default quota, campaign/character totals, live-room load
- GET users: created date, usage vs effective quota, session count, admin flag
- POST users/:name/revoke — log a user out everywhere
- DELETE users/:name — delete account + backup + owned campaigns + published
  characters (admin accounts are protected from deletion)
- GET/DELETE campaigns — oversight + removal incl. published characters
- GET rooms — players/seats/images/idle per room; join codes never exposed

Frontend: new /admin page (health cards, account management with quota editor +
two-click destructive confirms, campaign table, live-room table), ShieldCheck nav
entry via useIsAdmin(), Settings now links to the panel instead of embedding it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:49:59 +02:00
parent 0892b8f19c
commit 5119fc6d1c
14 changed files with 607 additions and 50 deletions
+85 -8
View File
@@ -4,8 +4,9 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { promises as fs } from 'node:fs';
import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts';
import { AccountStore, DEFAULT_QUOTA_BYTES } from './accounts';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
const PORT = Number(process.env.PORT ?? 8787);
@@ -179,26 +180,102 @@ export function buildServer() {
return { bytes, quota: accounts.quotaFor(u), admin: accounts.isAdmin(u.username) };
});
// ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ----
// ---- admin panel API (accounts in ADMIN_USERS only) ----
const startedAt = Date.now();
const adminOf = async (req: FastifyRequest) => {
const u = await userOf(req);
return u && accounts.isAdmin(u.username) ? u : null;
};
/** Recursive byte total of the data dir (users.json + cloud.json + blobs). */
const dirBytes = async (dir: string): Promise<number> => {
let total = 0;
try {
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) total += await dirBytes(p);
else total += (await fs.stat(p)).size.valueOf();
}
} catch { /* missing dir = 0 */ }
return total;
};
app.get('/api/admin/overview', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const mem = process.memoryUsage();
const roomStats = hub.stats();
const totals = await cloud.totals();
return {
uptimeMs: Date.now() - startedAt,
memory: { rss: mem.rss, heapUsed: mem.heapUsed },
dataDirBytes: await dirBytes(DATA_DIR),
users: { count: accounts.userCount(), max: Number.isFinite(MAX_USERS) ? MAX_USERS : null },
defaultQuotaBytes: DEFAULT_QUOTA_BYTES,
campaigns: totals.campaigns,
characters: totals.characters,
characterBytes: totals.bytes,
rooms: {
count: roomStats.length,
players: roomStats.reduce((n, r) => n + r.players, 0),
imageBytes: roomStats.reduce((n, r) => n + r.imageBytes, 0),
},
};
});
app.get('/api/admin/users', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
const users = await accounts.listUsers();
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const users = await accounts.listUsersDetailed();
const rows = await Promise.all(users.map(async (x) => ({
username: x.username,
admin: x.admin,
createdAt: x.createdAt,
usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)),
quotaBytes: x.quotaBytes ?? 0,
quotaBytes: x.quotaBytes,
effectiveQuota: x.quotaBytes > 0 ? x.quotaBytes : DEFAULT_QUOTA_BYTES,
tokenCount: x.tokenCount,
})));
return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) };
});
app.post('/api/admin/quota', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number };
const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
});
app.post('/api/admin/users/:username/revoke', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const ok = await accounts.revokeTokens((req.params as { username: string }).username);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
});
app.delete('/api/admin/users/:username', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const name = (req.params as { username: string }).username;
const id = await accounts.deleteUser(name);
if (!id) return reply.code(400).send({ error: 'cannot-delete', message: 'No such user, or the account is an admin.' });
const purged = await cloud.purgeUser(id);
return { ok: true, purged };
});
app.get('/api/admin/campaigns', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const byId = new Map((await accounts.listUsers()).map((x) => [x.id, x.username]));
const rows = (await cloud.adminList()).map((c) => ({ ...c, owner: byId.get(c.ownerUserId) ?? '(deleted)' }));
return { campaigns: rows.sort((a, b) => b.updatedAt - a.updatedAt) };
});
app.delete('/api/admin/campaigns/:id', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const ok = await cloud.deleteCampaign((req.params as { id: string }).id);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-campaign' });
});
app.get('/api/admin/rooms', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
return { rooms: hub.stats() };
});
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
// Live WebSocket connections per client IP — a hard cap on socket-spam.