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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
||||
import {
|
||||
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
@@ -24,6 +24,7 @@ import { SignalsBell } from '@/features/assistant/SignalsBell';
|
||||
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
|
||||
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
||||
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' };
|
||||
const NAV: NavItem[] = [
|
||||
@@ -100,6 +101,11 @@ export function RootLayout() {
|
||||
useSessionBroadcaster(activeCampaign ?? null);
|
||||
usePlayerConnection();
|
||||
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.
|
||||
useEffect(() => {
|
||||
@@ -148,7 +154,7 @@ export function RootLayout() {
|
||||
{GROUPS.map((group) => (
|
||||
<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>}
|
||||
{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 Ico = item.icon;
|
||||
return (
|
||||
|
||||
@@ -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 isn’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db/db';
|
||||
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 { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
@@ -22,12 +23,9 @@ export function CloudCampaigns() {
|
||||
const [viewing, setViewing] = useState<string | null>(null);
|
||||
const [party, setParty] = useState<CloudCharInfo[]>([]);
|
||||
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) 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 run = (fn: () => Promise<void>) => async () => {
|
||||
@@ -139,21 +137,9 @@ export function CloudCampaigns() {
|
||||
})()}
|
||||
|
||||
{usage?.admin && (
|
||||
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
|
||||
<h3 className="mb-1 smallcaps">Admin · users & storage <span className="font-normal text-muted">(you are signed in as the admin)</span></h3>
|
||||
<table className="w-full text-xs">
|
||||
<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 className="rounded-md border border-accent/30 bg-accent/5 p-2 text-sm">
|
||||
You’re the instance admin — manage accounts, storage, campaigns, and live rooms in the{' '}
|
||||
<Link to="/admin" className="text-accent underline">Admin panel</Link>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -168,15 +154,3 @@ function fmtBytes(b: number): string {
|
||||
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
@@ -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 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;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number }
|
||||
export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> {
|
||||
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 }) });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { HomebrewPage } from '@/features/world/HomebrewPage';
|
||||
import { SettingsPage } from '@/features/settings/SettingsPage';
|
||||
import { AssistantPage } from '@/features/assistant/AssistantPage';
|
||||
import { DirectorPage } from '@/features/assistant/director/DirectorPage';
|
||||
import { AdminPage } from '@/features/admin/AdminPage';
|
||||
|
||||
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 assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage });
|
||||
const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage });
|
||||
const adminRoute = createRoute({ getParentRoute: () => rootRoute, path: '/admin', component: AdminPage });
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
@@ -64,6 +66,7 @@ const routeTree = rootRoute.addChildren([
|
||||
settingsRoute,
|
||||
assistantRoute,
|
||||
directorRoute,
|
||||
adminRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound });
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user