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.ok).toBe(false);
expect((second as { code: string }).code).toBe('closed'); 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 } : {}) })); 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> { async setQuota(username: string, bytes: number): Promise<boolean> {
await this.load(); await this.load();
const u = this.users.get(username.toLowerCase()); const u = this.users.get(username.toLowerCase());
+24
View File
@@ -42,6 +42,30 @@ describe('CloudStore', () => {
expect(await store.usageBytes('p1')).toBeGreaterThan(0); 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 () => { it('rejects an oversized character and reports usage deltas', async () => {
const c = await store.createCampaign('gm1', 'C', '5e'); const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode); await store.joinByInvite('p1', c.inviteCode);
+52
View File
@@ -181,6 +181,58 @@ export class CloudStore {
return this.characters.get(characterId)?.ownerUserId === userId; 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. */ /** Total bytes a user is responsible for (their characters' data) — for usage tracking. */
async usageBytes(userId: string): Promise<number> { async usageBytes(userId: string): Promise<number> {
await this.load(); await this.load();
+85 -8
View File
@@ -4,8 +4,9 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { promises as fs } from 'node:fs';
import { RoomHub, type Sender } from './rooms'; import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts'; import { AccountStore, DEFAULT_QUOTA_BYTES } from './accounts';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns'; import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
const PORT = Number(process.env.PORT ?? 8787); 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) }; 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) => { app.get('/api/admin/users', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' }); const users = await accounts.listUsersDetailed();
const users = await accounts.listUsers();
const rows = await Promise.all(users.map(async (x) => ({ const rows = await Promise.all(users.map(async (x) => ({
username: x.username, username: x.username,
admin: x.admin,
createdAt: x.createdAt,
usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)), 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) }; return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) };
}); });
app.post('/api/admin/quota', async (req, reply) => { app.post('/api/admin/quota', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number }; const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number };
const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0); const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' }); 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 } }); void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
// Live WebSocket connections per client IP — a hard cap on socket-spam. // 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; } 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 { private gmRoom(socket: Sender, gmSecret: string): Room | null {
const conn = this.conns.get(socket); const conn = this.conns.get(socket);
if (!conn || conn.role !== 'gm') return null; if (!conn || conn.role !== 'gm') return null;
+8 -2
View File
@@ -3,7 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import { import {
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower, Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight, LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, ShieldCheck,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
@@ -24,6 +24,7 @@ import { SignalsBell } from '@/features/assistant/SignalsBell';
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator'; import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
import { SessionSidebar } from '@/features/play/SessionSidebar'; import { SessionSidebar } from '@/features/play/SessionSidebar';
import { useSessionStore } from '@/stores/sessionStore'; import { useSessionStore } from '@/stores/sessionStore';
import { useIsAdmin } from '@/features/admin/useIsAdmin';
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' }; type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' };
const NAV: NavItem[] = [ const NAV: NavItem[] = [
@@ -100,6 +101,11 @@ export function RootLayout() {
useSessionBroadcaster(activeCampaign ?? null); useSessionBroadcaster(activeCampaign ?? null);
usePlayerConnection(); usePlayerConnection();
useCloudAutosave(); useCloudAutosave();
// Instance admins get an extra nav entry (server re-checks every /api/admin call).
const isAdmin = useIsAdmin();
const nav: NavItem[] = isAdmin
? [...NAV, { to: '/admin', label: 'Admin', icon: ShieldCheck, exact: false, group: 'reference' }]
: NAV;
// Global Ctrl/Cmd+K opens the command palette. // Global Ctrl/Cmd+K opens the command palette.
useEffect(() => { useEffect(() => {
@@ -148,7 +154,7 @@ export function RootLayout() {
{GROUPS.map((group) => ( {GROUPS.map((group) => (
<div key={group.id}> <div key={group.id}>
{group.label && showLabel && <div className="smallcaps hidden px-6 pt-4 pb-1.5 sm:block" style={{ fontSize: 9.5 }}>{group.label}</div>} {group.label && showLabel && <div className="smallcaps hidden px-6 pt-4 pb-1.5 sm:block" style={{ fontSize: 9.5 }}>{group.label}</div>}
{NAV.filter((n) => n.group === group.id).map((item) => { {nav.filter((n) => n.group === group.id).map((item) => {
const active = item.exact ? pathname === item.to : pathname.startsWith(item.to); const active = item.exact ? pathname === item.to : pathname.startsWith(item.to);
const Ico = item.icon; const Ico = item.icon;
return ( return (
+263
View File
@@ -0,0 +1,263 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { RefreshCw, ShieldCheck, Users, Database, RadioTower, Cpu, Star } from 'lucide-react';
import { cloudUsername } from '@/lib/cloud/client';
import {
adminOverview, adminUsers, adminCampaigns, adminRooms,
adminSetQuota, adminRevokeTokens, adminDeleteUser, adminDeleteCampaign,
type AdminOverview, type AdminUser, type AdminCampaign, type AdminRoom,
} from '@/lib/cloud/admin';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
function fmtBytes(b: number): string {
if (b >= 1e9) return `${(b / 1e9).toFixed(2)} GB`;
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
return `${Math.max(0, Math.round(b / 1024))} KB`;
}
function fmtAgo(ms: number): string {
const m = Math.floor(ms / 60_000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
return h < 48 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d`;
}
const fmtDate = (t: number) => new Date(t).toLocaleDateString();
/**
* Instance administration: server health, every account (storage, quota,
* sessions), shared campaigns, and live rooms. Server-gated to ADMIN_USERS —
* this page only renders what /api/admin/* is willing to return.
*/
export function AdminPage() {
const username = cloudUsername();
const [overview, setOverview] = useState<AdminOverview | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]);
const [campaigns, setCampaigns] = useState<AdminCampaign[]>([]);
const [rooms, setRooms] = useState<AdminRoom[]>([]);
const [denied, setDenied] = useState(false);
const [msg, setMsg] = useState('');
const refresh = useCallback(() => {
setMsg('');
adminOverview().then(setOverview).catch(() => setDenied(true));
adminUsers().then((r) => setUsers(r.users)).catch(() => {});
adminCampaigns().then((r) => setCampaigns(r.campaigns)).catch(() => {});
adminRooms().then((r) => setRooms(r.rooms)).catch(() => {});
}, []);
useEffect(() => { if (username) refresh(); }, [username, refresh]);
if (!username) {
return (
<Page>
<PageHeader title="Admin" subtitle="Sign in with the admin account to manage this instance." />
<p className="text-sm text-muted">Go to <Link to="/settings" className="text-accent underline">Settings</Link> and sign in to the cloud first.</p>
</Page>
);
}
if (denied) {
return (
<Page>
<PageHeader title="Admin" />
<p className="text-sm text-warning">This account isnt an instance admin. Admins are set with the <code className="rounded bg-elevated px-1">ADMIN_USERS</code> environment variable on the server.</p>
</Page>
);
}
return (
<Page>
<PageHeader
eyebrow="Instance"
title="Admin"
subtitle={`Signed in as ${username}`}
actions={<Button size="sm" variant="secondary" onClick={refresh}><RefreshCw size={14} aria-hidden /> Refresh</Button>}
/>
{/* Server health */}
{overview && (
<div className="mb-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard icon={Users} label="Accounts" value={`${overview.users.count}${overview.users.max ? ` / ${overview.users.max}` : ''}`}
hint={`default quota ${fmtBytes(overview.defaultQuotaBytes)}`} />
<StatCard icon={Database} label="Stored data" value={fmtBytes(overview.dataDirBytes)}
hint={`${overview.campaigns} campaigns · ${overview.characters} published characters`} />
<StatCard icon={RadioTower} label="Live rooms" value={String(overview.rooms.count)}
hint={`${overview.rooms.players} players · ${fmtBytes(overview.rooms.imageBytes)} images in RAM`} />
<StatCard icon={Cpu} label="Memory (RSS)" value={fmtBytes(overview.memory.rss)}
hint={`heap ${fmtBytes(overview.memory.heapUsed)} · up ${fmtAgo(overview.uptimeMs)}`} />
</div>
)}
{/* Accounts */}
<Section icon={ShieldCheck} title={`Accounts (${users.length})`}>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1.5 font-medium">User</th>
<th className="font-medium">Created</th>
<th className="font-medium">Storage</th>
<th className="font-medium">Quota (MB)</th>
<th className="font-medium">Sessions</th>
<th className="font-medium" aria-label="Actions" />
</tr>
</thead>
<tbody>
{users.map((u) => (
<UserRow key={u.username} u={u} onChanged={refresh} onMsg={setMsg} />
))}
</tbody>
</table>
{users.length === 0 && <p className="py-2 text-sm text-muted">No accounts yet.</p>}
</Section>
{/* Campaigns */}
<Section icon={Database} title={`Shared campaigns (${campaigns.length})`}>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1.5 font-medium">Campaign</th>
<th className="font-medium">Owner</th>
<th className="font-medium">Members</th>
<th className="font-medium">Characters</th>
<th className="font-medium">Size</th>
<th className="font-medium">Updated</th>
<th className="font-medium" aria-label="Actions" />
</tr>
</thead>
<tbody>
{campaigns.map((c) => (
<CampaignRow key={c.id} c={c} onChanged={refresh} onMsg={setMsg} />
))}
</tbody>
</table>
{campaigns.length === 0 && <p className="py-2 text-sm text-muted">No shared campaigns yet.</p>}
</Section>
{/* Live rooms */}
<Section icon={RadioTower} title={`Live rooms (${rooms.length})`}>
{rooms.length === 0 ? (
<p className="py-2 text-sm text-muted">No active play sessions. Rooms expire after 6h idle.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1.5 font-medium">GM</th>
<th className="font-medium">Players</th>
<th className="font-medium">Seats</th>
<th className="font-medium">Map images</th>
<th className="font-medium">Idle</th>
</tr>
</thead>
<tbody>
{rooms.map((r, i) => (
<tr key={i} className="border-t border-line">
<td className="py-1.5">{r.hasGm ? <span className="text-success">connected</span> : <span className="text-muted">away</span>}</td>
<td>{r.players}</td>
<td>{r.seats}</td>
<td className="text-muted">{r.images} · {fmtBytes(r.imageBytes)}</td>
<td className="text-muted">{fmtAgo(r.idleMs)}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="mt-1 text-[11px] text-muted">Rooms are anonymous by design join codes and contents are never shown here.</p>
</Section>
{msg && <p className="mt-3 text-sm text-accent">{msg}</p>}
</Page>
);
}
function StatCard({ icon: Icon, label, value, hint }: { icon: typeof Users; label: string; value: string; hint: string }) {
return (
<div className="rounded-xl border border-line bg-panel p-4">
<div className="flex items-center gap-1.5 smallcaps"><Icon size={13} aria-hidden /> {label}</div>
<div className="mt-1 font-display text-2xl font-semibold text-ink">{value}</div>
<div className="mt-0.5 text-xs text-muted">{hint}</div>
</div>
);
}
function Section({ icon: Icon, title, children }: { icon: typeof Users; title: string; children: React.ReactNode }) {
return (
<section className="mb-6 rounded-xl border border-line bg-panel p-4">
<h2 className="mb-2 flex items-center gap-1.5 smallcaps"><Icon size={13} aria-hidden /> {title}</h2>
{children}
</section>
);
}
/** Destructive actions arm on the first click and fire on the second. */
function ConfirmButton({ label, confirmLabel, onConfirm }: { label: string; confirmLabel: string; onConfirm: () => void }) {
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!armed) return;
const t = setTimeout(() => setArmed(false), 4000);
return () => clearTimeout(t);
}, [armed]);
return (
<Button size="sm" variant={armed ? 'danger' : 'ghost'} onClick={() => (armed ? onConfirm() : setArmed(true))}>
{armed ? confirmLabel : label}
</Button>
);
}
function UserRow({ u, onChanged, onMsg }: { u: AdminUser; onChanged: () => void; onMsg: (m: string) => void }) {
const [mb, setMb] = useState(u.quotaBytes ? String(Math.round(u.quotaBytes / 1e6)) : '');
const pct = Math.min(100, Math.round((u.usageBytes / Math.max(1, u.effectiveQuota)) * 100));
const act = (p: Promise<unknown>, done: string) => p.then(() => { onMsg(done); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.'));
return (
<tr className="border-t border-line align-middle">
<td className="py-1.5 text-ink">
{u.username}
{u.admin && <span className="ml-1 inline-flex items-center gap-0.5 rounded bg-accent/10 px-1 text-[10px] text-accent"><Star size={9} aria-hidden /> admin</span>}
</td>
<td className="text-muted">{fmtDate(u.createdAt)}</td>
<td>
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-elevated">
<div className={pct >= 90 ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted">{fmtBytes(u.usageBytes)} / {fmtBytes(u.effectiveQuota)}</span>
</div>
</td>
<td>
<div className="flex items-center gap-1">
<Input className="w-20" value={mb} placeholder="default" aria-label={`Quota for ${u.username} in MB`} onChange={(e) => setMb(e.target.value)} />
<Button size="sm" variant="ghost" onClick={() => act(adminSetQuota(u.username, (Number(mb) || 0) * 1e6), `Quota for ${u.username} ${Number(mb) ? `set to ${mb} MB` : 'reset to default'}.`)}>Set</Button>
</div>
</td>
<td>
<div className="flex items-center gap-1 text-muted">
{u.tokenCount}
{u.tokenCount > 0 && (
<ConfirmButton label="Revoke" confirmLabel="Log out everywhere?" onConfirm={() => act(adminRevokeTokens(u.username), `${u.username} signed out everywhere.`)} />
)}
</div>
</td>
<td className="text-right">
{!u.admin && (
<ConfirmButton label="Delete" confirmLabel="Delete account + data?" onConfirm={() => act(adminDeleteUser(u.username), `${u.username} deleted (account, backup, campaigns, characters).`)} />
)}
</td>
</tr>
);
}
function CampaignRow({ c, onChanged, onMsg }: { c: AdminCampaign; onChanged: () => void; onMsg: (m: string) => void }) {
return (
<tr className="border-t border-line">
<td className="py-1.5 text-ink">{c.name} <span className="text-[10px] uppercase text-muted">{c.system}</span></td>
<td className="text-muted">{c.owner}</td>
<td>{c.members}</td>
<td>{c.characters}</td>
<td className="text-muted">{fmtBytes(c.bytes)}</td>
<td className="text-muted">{fmtDate(c.updatedAt)}</td>
<td className="text-right">
<ConfirmButton label="Delete" confirmLabel="Delete campaign + characters?" onConfirm={() =>
adminDeleteCampaign(c.id).then(() => { onMsg(`Campaign “${c.name}” deleted.`); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.'))} />
</td>
</tr>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import { cloudUsername } from '@/lib/cloud/client';
import { getUsage } from '@/lib/cloud/campaigns';
// Cache across mounts so the sidebar doesn't re-ask /api/usage on every navigation.
let cached: boolean | null = null;
/** Whether the signed-in cloud account is an instance admin (false while unknown). */
export function useIsAdmin(): boolean {
const [isAdmin, setIsAdmin] = useState(cached === true);
useEffect(() => {
if (cached !== null || !cloudUsername()) return;
let on = true;
getUsage()
.then((u) => { cached = u.admin; if (on) setIsAdmin(u.admin); })
.catch(() => { /* signed out / offline — stay hidden */ });
return () => { on = false; };
}, []);
return isAdmin;
}
+5 -31
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db'; import { db } from '@/lib/db/db';
import { cloudUsername, CloudError } from '@/lib/cloud/client'; import { cloudUsername, CloudError } from '@/lib/cloud/client';
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns'; import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns';
import { useCampaigns } from '@/features/campaigns/hooks'; import { useCampaigns } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
@@ -22,12 +23,9 @@ export function CloudCampaigns() {
const [viewing, setViewing] = useState<string | null>(null); const [viewing, setViewing] = useState<string | null>(null);
const [party, setParty] = useState<CloudCharInfo[]>([]); const [party, setParty] = useState<CloudCharInfo[]>([]);
const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null); const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null);
const [admins, setAdmins] = useState<AdminUserRow[]>([]);
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]); useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]); useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]);
const loadAdmin = () => adminListUsers().then((r) => setAdmins(r.users)).catch(() => {});
useEffect(() => { if (usage?.admin) loadAdmin(); }, [usage?.admin]);
const refresh = () => listCloudCampaigns().then(setList).catch(() => {}); const refresh = () => listCloudCampaigns().then(setList).catch(() => {});
const run = (fn: () => Promise<void>) => async () => { const run = (fn: () => Promise<void>) => async () => {
@@ -139,21 +137,9 @@ export function CloudCampaigns() {
})()} })()}
{usage?.admin && ( {usage?.admin && (
<div className="rounded-md border border-accent/30 bg-accent/5 p-2"> <div className="rounded-md border border-accent/30 bg-accent/5 p-2 text-sm">
<h3 className="mb-1 smallcaps">Admin · users &amp; storage <span className="font-normal text-muted">(you are signed in as the admin)</span></h3> Youre the instance admin manage accounts, storage, campaigns, and live rooms in the{' '}
<table className="w-full text-xs"> <Link to="/admin" className="text-accent underline">Admin panel</Link>.
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
<tbody>
{admins.map((u) => (
<tr key={u.username} className="border-t border-line">
<td className="py-1 text-ink">{u.username}</td>
<td className="py-1 text-muted">{fmtBytes(u.usageBytes)}</td>
<td className="py-1"><QuotaCell user={u} onSaved={loadAdmin} /></td>
</tr>
))}
</tbody>
</table>
<p className="mt-1 text-[11px] text-muted">Quotas are tracked but not yet enforced a safety valve for later.</p>
</div> </div>
)} )}
@@ -168,15 +154,3 @@ function fmtBytes(b: number): string {
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`; if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
return `${Math.max(0, Math.round(b / 1024))} KB`; return `${Math.max(0, Math.round(b / 1024))} KB`;
} }
function QuotaCell({ user, onSaved }: { user: AdminUserRow; onSaved: () => void }) {
const [gb, setGb] = useState(String(user.quotaBytes ? (user.quotaBytes / 1e9).toFixed(0) : '0'));
const [saving, setSaving] = useState(false);
const save = async () => { setSaving(true); try { await adminSetQuota(user.username, (Number(gb) || 0) * 1e9); onSaved(); } catch { /* ignore */ } finally { setSaving(false); } };
return (
<span className="flex items-center gap-1">
<input value={gb} onChange={(e) => setGb(e.target.value.replace(/[^0-9]/g, ''))} className="w-12 rounded border border-line bg-surface px-1 py-0.5 text-xs" aria-label={`Quota GB for ${user.username}`} />
<Button size="sm" variant="ghost" disabled={saving} onClick={() => void save()}>Set</Button>
</span>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { req } from './campaigns';
/** Typed client for the admin-panel API (accounts listed in ADMIN_USERS only). */
export interface AdminOverview {
uptimeMs: number;
memory: { rss: number; heapUsed: number };
dataDirBytes: number;
users: { count: number; max: number | null };
defaultQuotaBytes: number;
campaigns: number;
characters: number;
characterBytes: number;
rooms: { count: number; players: number; imageBytes: number };
}
export interface AdminUser {
username: string;
admin: boolean;
createdAt: number;
usageBytes: number;
quotaBytes: number;
effectiveQuota: number;
tokenCount: number;
}
export interface AdminCampaign {
id: string;
name: string;
system: string;
owner: string;
members: number;
characters: number;
bytes: number;
updatedAt: number;
}
export interface AdminRoom {
players: number;
seats: number;
images: number;
imageBytes: number;
idleMs: number;
hasGm: boolean;
}
export const adminOverview = () => req<AdminOverview>('/admin/overview');
export const adminUsers = () => req<{ users: AdminUser[] }>('/admin/users');
export const adminCampaigns = () => req<{ campaigns: AdminCampaign[] }>('/admin/campaigns');
export const adminRooms = () => req<{ rooms: AdminRoom[] }>('/admin/rooms');
export const adminSetQuota = (username: string, quotaBytes: number) =>
req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) });
export const adminRevokeTokens = (username: string) =>
req<{ ok: boolean }>(`/admin/users/${encodeURIComponent(username)}/revoke`, { method: 'POST' });
export const adminDeleteUser = (username: string) =>
req<{ ok: boolean; purged: { campaigns: number; characters: number } }>(`/admin/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
export const adminDeleteCampaign = (id: string) =>
req<{ ok: boolean }>(`/admin/campaigns/${encodeURIComponent(id)}`, { method: 'DELETE' });
+2 -8
View File
@@ -12,7 +12,8 @@ function authHeaders(): Record<string, string> {
export interface CloudCampaignInfo { id: string; name: string; system: string; role: 'owner' | 'member'; inviteCode?: string } export interface CloudCampaignInfo { id: string; name: string; system: string; role: 'owner' | 'member'; inviteCode?: string }
export interface CloudCharInfo { id: string; name: string; ownerUserId: string; mine: boolean; data: string; updatedAt: number } export interface CloudCharInfo { id: string; name: string; ownerUserId: string; mine: boolean; data: string; updatedAt: number }
async function req<T>(path: string, init?: RequestInit): Promise<T> { /** Authenticated same-origin /api request — shared by the campaign + admin clients. */
export async function req<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response; let res: Response;
try { try {
res = await fetch(`${base()}${path}`, { ...init, headers: { 'content-type': 'application/json', ...authHeaders(), ...(init?.headers ?? {}) } }); res = await fetch(`${base()}${path}`, { ...init, headers: { 'content-type': 'application/json', ...authHeaders(), ...(init?.headers ?? {}) } });
@@ -52,13 +53,6 @@ export async function cloudUsageBytes(): Promise<number> {
return (await req<{ bytes: number }>('/usage')).bytes; return (await req<{ bytes: number }>('/usage')).bytes;
} }
export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number }
export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> { export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> {
return req<{ bytes: number; quota: number; admin: boolean }>('/usage'); return req<{ bytes: number; quota: number; admin: boolean }>('/usage');
} }
export function adminListUsers(): Promise<{ users: AdminUserRow[] }> {
return req<{ users: AdminUserRow[] }>('/admin/users');
}
export function adminSetQuota(username: string, quotaBytes: number): Promise<{ ok: boolean }> {
return req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) });
}
+3
View File
@@ -19,6 +19,7 @@ import { HomebrewPage } from '@/features/world/HomebrewPage';
import { SettingsPage } from '@/features/settings/SettingsPage'; import { SettingsPage } from '@/features/settings/SettingsPage';
import { AssistantPage } from '@/features/assistant/AssistantPage'; import { AssistantPage } from '@/features/assistant/AssistantPage';
import { DirectorPage } from '@/features/assistant/director/DirectorPage'; import { DirectorPage } from '@/features/assistant/director/DirectorPage';
import { AdminPage } from '@/features/admin/AdminPage';
const rootRoute = createRootRoute({ component: RootLayout }); const rootRoute = createRootRoute({ component: RootLayout });
@@ -44,6 +45,7 @@ const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/hom
const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage }); const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage });
const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage }); const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage });
const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage }); const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage });
const adminRoute = createRoute({ getParentRoute: () => rootRoute, path: '/admin', component: AdminPage });
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
@@ -64,6 +66,7 @@ const routeTree = rootRoute.addChildren([
settingsRoute, settingsRoute,
assistantRoute, assistantRoute,
directorRoute, directorRoute,
adminRoute,
]); ]);
export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound }); export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound });
File diff suppressed because one or more lines are too long