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
+34
View File
@@ -72,4 +72,38 @@ describe('AccountStore', () => {
expect(second.ok).toBe(false);
expect((second as { code: string }).code).toBe('closed');
});
it('revokes all tokens (log out everywhere)', async () => {
const r = await store.register('revokee', 'password123');
if (!r.ok) throw new Error('register failed');
expect(await store.userByToken(r.token)).toBeTruthy();
expect(await store.revokeTokens('revokee')).toBe(true);
expect(await store.userByToken(r.token)).toBeNull();
expect(await store.revokeTokens('ghost')).toBe(false);
expect((await store.login('revokee', 'password123')).ok).toBe(true); // password still works
});
it('deletes a user + their blob, but never an admin', async () => {
const s = new AccountStore(dir, undefined, ['boss2']);
await s.register('boss2', 'password123');
const r = await s.register('victim', 'password123');
if (!r.ok) throw new Error('register failed');
const u = (await s.userByToken(r.token))!;
await s.saveBlob(u.id, '{"x":1}');
expect(await s.deleteUser('boss2')).toBeNull(); // admins are protected
const removedId = await s.deleteUser('victim');
expect(removedId).toBe(u.id);
expect(await s.userByToken(r.token)).toBeNull(); // tokens dead
expect(await s.loadBlob(u.id)).toBeNull(); // blob gone
expect((await s.login('victim', 'password123')).ok).toBe(false); // account gone
});
it('lists detailed rows for the admin panel', async () => {
const r = await store.register('detailed', 'password123');
if (!r.ok) throw new Error('register failed');
const row = (await store.listUsersDetailed()).find((x) => x.username === 'detailed')!;
expect(row.tokenCount).toBe(1);
expect(row.admin).toBe(false);
expect(row.createdAt).toBeGreaterThan(0);
});
});
+38
View File
@@ -65,6 +65,44 @@ export class AccountStore {
return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) }));
}
/** Admin detail rows — everything the panel shows except usage (caller joins that). */
async listUsersDetailed(): Promise<Array<{ id: string; username: string; createdAt: number; updatedAt: number; quotaBytes: number; tokenCount: number; admin: boolean }>> {
await this.load();
return [...this.users.values()].map((u) => ({
id: u.id, username: u.username, createdAt: u.createdAt, updatedAt: u.updatedAt,
quotaBytes: u.quotaBytes ?? 0, tokenCount: u.tokens.length, admin: this.isAdmin(u.username),
}));
}
/** Invalidate every session token for a user ("log out everywhere"). */
async revokeTokens(username: string): Promise<boolean> {
await this.load();
const u = this.users.get(username.toLowerCase());
if (!u) return false;
for (const h of u.tokens) this.byToken.delete(h);
u.tokens = [];
u.updatedAt = this.now();
await this.persist();
return true;
}
/**
* Delete an account and its backup blob. Admin accounts can't be deleted from the
* panel (protects against lockout + a compromised admin nuking another admin).
* Returns the removed user's id so the caller can purge their cloud data too.
*/
async deleteUser(username: string): Promise<string | null> {
await this.load();
const u = this.users.get(username.toLowerCase());
if (!u || this.isAdmin(u.username)) return null;
for (const h of u.tokens) this.byToken.delete(h);
this.users.delete(u.username.toLowerCase());
this.byId.delete(u.id);
try { await fs.unlink(this.blobFile(u.id)); } catch { /* no blob */ }
await this.persist();
return u.id;
}
async setQuota(username: string, bytes: number): Promise<boolean> {
await this.load();
const u = this.users.get(username.toLowerCase());
+24
View File
@@ -42,6 +42,30 @@ describe('CloudStore', () => {
expect(await store.usageBytes('p1')).toBeGreaterThan(0);
});
it('admin: lists, deletes campaigns, and purges a user wholesale', async () => {
const mine = await store.createCampaign('gm1', 'Mine', '5e');
const theirs = await store.createCampaign('gm2', 'Theirs', 'pf2e');
await store.joinByInvite('gm1', theirs.inviteCode); // gm1 is also a member elsewhere
await store.putCharacter('gm1', mine.id, { id: 'c1', name: 'A', data: '{"a":1}' });
await store.putCharacter('gm1', theirs.id, { id: 'c2', name: 'B', data: '{"b":2}' });
const rows = await store.adminList();
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.id === mine.id)).toMatchObject({ characters: 1, members: 0 });
// Purging gm1 removes the campaign they OWN (+ its chars), their membership,
// and their characters published in other campaigns.
const purged = await store.purgeUser('gm1');
expect(purged).toEqual({ campaigns: 1, characters: 2 });
expect(store.isMember(theirs.id, 'gm1')).toBe(false);
expect(await store.joinByInvite('p9', mine.inviteCode)).toBeNull(); // invite dead
expect((await store.totals()).campaigns).toBe(1);
expect(await store.deleteCampaign(theirs.id)).toBe(true);
expect(await store.deleteCampaign(theirs.id)).toBe(false); // already gone
expect((await store.totals()).campaigns).toBe(0);
});
it('rejects an oversized character and reports usage deltas', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
+52
View File
@@ -181,6 +181,58 @@ export class CloudStore {
return this.characters.get(characterId)?.ownerUserId === userId;
}
/** Admin overview rows: every campaign with member/character counts + stored bytes. */
async adminList(): Promise<Array<{ id: string; name: string; system: string; ownerUserId: string; members: number; characters: number; bytes: number; createdAt: number; updatedAt: number }>> {
await this.load();
return [...this.campaigns.values()].map((c) => {
let characters = 0, bytes = 0;
for (const ch of this.characters.values()) if (ch.campaignId === c.id) { characters++; bytes += Buffer.byteLength(ch.data); }
return { id: c.id, name: c.name, system: c.system, ownerUserId: c.ownerUserId, members: c.members.length, characters, bytes, createdAt: c.createdAt, updatedAt: c.updatedAt };
});
}
/** Admin: delete a campaign and every character published into it. */
async deleteCampaign(campaignId: string): Promise<boolean> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return false;
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(campaignId);
for (const [id, ch] of this.characters) if (ch.campaignId === campaignId) this.characters.delete(id);
await this.persist();
return true;
}
/**
* Admin: purge everything a deleted user left behind — campaigns they own (with
* those campaigns' characters), their memberships, and their published characters.
*/
async purgeUser(userId: string): Promise<{ campaigns: number; characters: number }> {
await this.load();
let campaigns = 0, characters = 0;
for (const [id, c] of [...this.campaigns]) {
if (c.ownerUserId === userId) {
campaigns++;
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(id);
for (const [chId, ch] of [...this.characters]) if (ch.campaignId === id) { this.characters.delete(chId); characters++; }
} else if (c.members.includes(userId)) {
c.members = c.members.filter((m) => m !== userId);
}
}
for (const [chId, ch] of [...this.characters]) if (ch.ownerUserId === userId) { this.characters.delete(chId); characters++; }
await this.persist();
return { campaigns, characters };
}
/** Instance-wide totals for the admin overview. */
async totals(): Promise<{ campaigns: number; characters: number; bytes: number }> {
await this.load();
let bytes = 0;
for (const ch of this.characters.values()) bytes += Buffer.byteLength(ch.data);
return { campaigns: this.campaigns.size, characters: this.characters.size, bytes };
}
/** Total bytes a user is responsible for (their characters' data) — for usage tracking. */
async usageBytes(userId: string): Promise<number> {
await this.load();
+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.
+14
View File
@@ -347,6 +347,20 @@ export class RoomHub {
roomCount(): number { return this.rooms.size; }
/** Admin overview of live rooms. Deliberately excludes join codes and secrets —
* the admin can see load, not enter private games. */
stats(): Array<{ players: number; seats: number; images: number; imageBytes: number; idleMs: number; hasGm: boolean }> {
const now = this.now();
return [...this.rooms.values()].map((r) => ({
players: r.players.size,
seats: r.seats.size,
images: r.images.size,
imageBytes: r.imageBytes,
idleMs: Math.max(0, now - r.lastActivity),
hasGm: r.gm !== null,
}));
}
private gmRoom(socket: Sender, gmSecret: string): Room | null {
const conn = this.conns.get(socket);
if (!conn || conn.role !== 'gm') return null;