From 5119fc6d1c12d46a2722f750f092c49af287bed1 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 20:49:59 +0200 Subject: [PATCH] Proper admin panel at /admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/accounts.test.ts | 34 +++ server/src/accounts.ts | 38 ++++ server/src/campaigns.test.ts | 24 +++ server/src/campaigns.ts | 52 +++++ server/src/index.ts | 93 +++++++- server/src/rooms.ts | 14 ++ src/app/RootLayout.tsx | 10 +- src/features/admin/AdminPage.tsx | 263 +++++++++++++++++++++++ src/features/admin/useIsAdmin.ts | 20 ++ src/features/settings/CloudCampaigns.tsx | 36 +--- src/lib/cloud/admin.ts | 58 +++++ src/lib/cloud/campaigns.ts | 10 +- src/router.tsx | 3 + tsconfig.app.tsbuildinfo | 2 +- 14 files changed, 607 insertions(+), 50 deletions(-) create mode 100644 src/features/admin/AdminPage.tsx create mode 100644 src/features/admin/useIsAdmin.ts create mode 100644 src/lib/cloud/admin.ts diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index 6af86c2..2f223aa 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -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); + }); }); diff --git a/server/src/accounts.ts b/server/src/accounts.ts index c774a20..362084d 100644 --- a/server/src/accounts.ts +++ b/server/src/accounts.ts @@ -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> { + 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 { + 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 { + 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 { await this.load(); const u = this.users.get(username.toLowerCase()); diff --git a/server/src/campaigns.test.ts b/server/src/campaigns.test.ts index 49fd8d4..4b50e09 100644 --- a/server/src/campaigns.test.ts +++ b/server/src/campaigns.test.ts @@ -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); diff --git a/server/src/campaigns.ts b/server/src/campaigns.ts index f6915fa..f782f14 100644 --- a/server/src/campaigns.ts +++ b/server/src/campaigns.ts @@ -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> { + 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 { + 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 { await this.load(); diff --git a/server/src/index.ts b/server/src/index.ts index eb8f70e..d108839 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 => { + 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. diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 4276e12..15aca4d 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -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; diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 6c10328..6b426a2 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -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) => (
{group.label && showLabel &&
{group.label}
} - {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 ( diff --git a/src/features/admin/AdminPage.tsx b/src/features/admin/AdminPage.tsx new file mode 100644 index 0000000..6e50b88 --- /dev/null +++ b/src/features/admin/AdminPage.tsx @@ -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(null); + const [users, setUsers] = useState([]); + const [campaigns, setCampaigns] = useState([]); + const [rooms, setRooms] = useState([]); + 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 ( + + +

Go to Settings and sign in to the cloud first.

+
+ ); + } + if (denied) { + return ( + + +

This account isn’t an instance admin. Admins are set with the ADMIN_USERS environment variable on the server.

+
+ ); + } + + return ( + + Refresh} + /> + + {/* Server health */} + {overview && ( +
+ + + + +
+ )} + + {/* Accounts */} +
+ + + + + + + + + + + + {users.map((u) => ( + + ))} + +
UserCreatedStorageQuota (MB)Sessions +
+ {users.length === 0 &&

No accounts yet.

} +
+ + {/* Campaigns */} +
+ + + + + + + + + + + + + {campaigns.map((c) => ( + + ))} + +
CampaignOwnerMembersCharactersSizeUpdated +
+ {campaigns.length === 0 &&

No shared campaigns yet.

} +
+ + {/* Live rooms */} +
+ {rooms.length === 0 ? ( +

No active play sessions. Rooms expire after 6h idle.

+ ) : ( + + + + + + + + + + + + {rooms.map((r, i) => ( + + + + + + + + ))} + +
GMPlayersSeatsMap imagesIdle
{r.hasGm ? connected : away}{r.players}{r.seats}{r.images} · {fmtBytes(r.imageBytes)}{fmtAgo(r.idleMs)}
+ )} +

Rooms are anonymous by design — join codes and contents are never shown here.

+
+ + {msg &&

{msg}

} +
+ ); +} + +function StatCard({ icon: Icon, label, value, hint }: { icon: typeof Users; label: string; value: string; hint: string }) { + return ( +
+
{label}
+
{value}
+
{hint}
+
+ ); +} + +function Section({ icon: Icon, title, children }: { icon: typeof Users; title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +/** 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 ( + + ); +} + +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, done: string) => p.then(() => { onMsg(done); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.')); + return ( + + + {u.username} + {u.admin && admin} + + {fmtDate(u.createdAt)} + +
+
+
= 90 ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} /> +
+ {fmtBytes(u.usageBytes)} / {fmtBytes(u.effectiveQuota)} +
+ + +
+ setMb(e.target.value)} /> + +
+ + +
+ {u.tokenCount} + {u.tokenCount > 0 && ( + act(adminRevokeTokens(u.username), `${u.username} signed out everywhere.`)} /> + )} +
+ + + {!u.admin && ( + act(adminDeleteUser(u.username), `${u.username} deleted (account, backup, campaigns, characters).`)} /> + )} + + + ); +} + +function CampaignRow({ c, onChanged, onMsg }: { c: AdminCampaign; onChanged: () => void; onMsg: (m: string) => void }) { + return ( + + {c.name} {c.system} + {c.owner} + {c.members} + {c.characters} + {fmtBytes(c.bytes)} + {fmtDate(c.updatedAt)} + + + adminDeleteCampaign(c.id).then(() => { onMsg(`Campaign “${c.name}” deleted.`); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.'))} /> + + + ); +} diff --git a/src/features/admin/useIsAdmin.ts b/src/features/admin/useIsAdmin.ts new file mode 100644 index 0000000..ecbb94f --- /dev/null +++ b/src/features/admin/useIsAdmin.ts @@ -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; +} diff --git a/src/features/settings/CloudCampaigns.tsx b/src/features/settings/CloudCampaigns.tsx index d947263..b385d0d 100644 --- a/src/features/settings/CloudCampaigns.tsx +++ b/src/features/settings/CloudCampaigns.tsx @@ -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(null); const [party, setParty] = useState([]); const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null); - const [admins, setAdmins] = useState([]); 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) => async () => { @@ -139,21 +137,9 @@ export function CloudCampaigns() { })()} {usage?.admin && ( -
-

Admin · users & storage (you are signed in as the admin)

- - - - {admins.map((u) => ( - - - - - - ))} - -
UserUsedQuota (GB, 0=default)
{u.username}{fmtBytes(u.usageBytes)}
-

Quotas are tracked but not yet enforced — a safety valve for later.

+
+ You’re the instance admin — manage accounts, storage, campaigns, and live rooms in the{' '} + Admin panel.
)} @@ -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 ( - - 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}`} /> - - - ); -} diff --git a/src/lib/cloud/admin.ts b/src/lib/cloud/admin.ts new file mode 100644 index 0000000..5b090e6 --- /dev/null +++ b/src/lib/cloud/admin.ts @@ -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('/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' }); diff --git a/src/lib/cloud/campaigns.ts b/src/lib/cloud/campaigns.ts index 506d2a8..92d284f 100644 --- a/src/lib/cloud/campaigns.ts +++ b/src/lib/cloud/campaigns.ts @@ -12,7 +12,8 @@ function authHeaders(): Record { 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(path: string, init?: RequestInit): Promise { +/** Authenticated same-origin /api request — shared by the campaign + admin clients. */ +export async function req(path: string, init?: RequestInit): Promise { 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 { 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 }) }); -} diff --git a/src/router.tsx b/src/router.tsx index a08b032..1f9d718 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -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 }); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 9941b6d..9a6b19b 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/NotFound.tsx","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/components/ui/useConfirm.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/SignalsBell.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/assistant/useSignalAction.ts","./src/features/assistant/director/ActionCard.tsx","./src/features/assistant/director/DirectorPage.tsx","./src/features/assistant/director/RollRequestCard.tsx","./src/features/assistant/director/SceneControls.tsx","./src/features/assistant/director/SuggestionChips.tsx","./src/features/assistant/director/TranscriptPane.tsx","./src/features/assistant/director/actionSummary.ts","./src/features/assistant/director/hooks.ts","./src/features/assistant/director/useDirectorAction.test.ts","./src/features/assistant/director/useDirectorAction.ts","./src/features/assistant/director/useDirectorSession.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/ClassFeaturesSection.tsx","./src/features/characters/sheet/ClassesEditor.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatPickerModal.tsx","./src/features/characters/sheet/FeatsSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/WeaponPickerModal.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.tsx","./src/features/cloud/useCloudAutosave.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.tsx","./src/features/settings/DirectorSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/assistant/signals.test.ts","./src/lib/assistant/signals.ts","./src/lib/assistant/director/context.ts","./src/lib/assistant/director/director.test.ts","./src/lib/assistant/director/engine.ts","./src/lib/assistant/director/fallback.ts","./src/lib/assistant/director/index.ts","./src/lib/assistant/director/no-auto-roll.test.ts","./src/lib/assistant/director/prompt.ts","./src/lib/assistant/director/resolve.ts","./src/lib/assistant/director/schema.ts","./src/lib/assistant/director/summarize.test.ts","./src/lib/assistant/director/summarize.ts","./src/lib/assistant/director/types.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/mpmb.test.ts","./src/lib/compendium/mpmb.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/img/resize.test.ts","./src/lib/img/resize.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/cast.ts","./src/lib/mechanics/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.ts","./src/lib/mechanics/deathSaves.ts","./src/lib/mechanics/dying.test.ts","./src/lib/mechanics/dying.ts","./src/lib/mechanics/enforce.test.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/resources.ts","./src/lib/mechanics/result.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityBuild.test.ts","./src/lib/rules/abilityBuild.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/multiclass.test.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/features/collect.ts","./src/lib/rules/features/data.5e.ts","./src/lib/rules/features/data.pf2e.ts","./src/lib/rules/features/features.test.ts","./src/lib/rules/features/subclass.5e.ts","./src/lib/rules/features/types.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.ts","./src/lib/schemas/aiSession.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/connectivityStore.ts","./src/stores/directorStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/NotFound.tsx","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/components/ui/useConfirm.tsx","./src/features/admin/AdminPage.tsx","./src/features/admin/useIsAdmin.ts","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/SignalsBell.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/assistant/useSignalAction.ts","./src/features/assistant/director/ActionCard.tsx","./src/features/assistant/director/DirectorPage.tsx","./src/features/assistant/director/RollRequestCard.tsx","./src/features/assistant/director/SceneControls.tsx","./src/features/assistant/director/SuggestionChips.tsx","./src/features/assistant/director/TranscriptPane.tsx","./src/features/assistant/director/actionSummary.ts","./src/features/assistant/director/hooks.ts","./src/features/assistant/director/useDirectorAction.test.ts","./src/features/assistant/director/useDirectorAction.ts","./src/features/assistant/director/useDirectorSession.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/ClassFeaturesSection.tsx","./src/features/characters/sheet/ClassesEditor.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatPickerModal.tsx","./src/features/characters/sheet/FeatsSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/WeaponPickerModal.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.tsx","./src/features/cloud/useCloudAutosave.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.tsx","./src/features/settings/DirectorSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/assistant/signals.test.ts","./src/lib/assistant/signals.ts","./src/lib/assistant/director/context.ts","./src/lib/assistant/director/director.test.ts","./src/lib/assistant/director/engine.ts","./src/lib/assistant/director/fallback.ts","./src/lib/assistant/director/index.ts","./src/lib/assistant/director/no-auto-roll.test.ts","./src/lib/assistant/director/prompt.ts","./src/lib/assistant/director/resolve.ts","./src/lib/assistant/director/schema.ts","./src/lib/assistant/director/summarize.test.ts","./src/lib/assistant/director/summarize.ts","./src/lib/assistant/director/types.ts","./src/lib/cloud/admin.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/mpmb.test.ts","./src/lib/compendium/mpmb.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/img/resize.test.ts","./src/lib/img/resize.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/cast.ts","./src/lib/mechanics/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.ts","./src/lib/mechanics/deathSaves.ts","./src/lib/mechanics/dying.test.ts","./src/lib/mechanics/dying.ts","./src/lib/mechanics/enforce.test.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/resources.ts","./src/lib/mechanics/result.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityBuild.test.ts","./src/lib/rules/abilityBuild.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/multiclass.test.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/features/collect.ts","./src/lib/rules/features/data.5e.ts","./src/lib/rules/features/data.pf2e.ts","./src/lib/rules/features/features.test.ts","./src/lib/rules/features/subclass.5e.ts","./src/lib/rules/features/types.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.ts","./src/lib/schemas/aiSession.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/connectivityStore.ts","./src/stores/directorStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file