Proper admin panel at /admin

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:49:59 +02:00
parent 0892b8f19c
commit 5119fc6d1c
14 changed files with 607 additions and 50 deletions
+8 -2
View File
@@ -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 (