Refactor admin UI for consolidated navigation and improved moderation workflow

- Updated Sidebar component to consolidate navigation items under new categories: Moderation, Content, and Operations.
- Removed outdated pages and integrated their functionalities into tabs under Players, Activity, Content, and Settings.
- Introduced new Activity and Content pages to house Audit, Player Logs, Community Board, and Loading Tips with tabbed navigation.
- Created a unified Moderation page combining Players, Bans, and Reports with tabbed navigation.
- Refactored individual pages (e.g., Audit, Bans, CommunityBoard, etc.) to support embedded mode for better integration within modals.
- Added Tabs component for consistent tab navigation across multiple pages.
- Updated README to reflect changes in navigation and settings structure.
- Removed Grants page as it is not yet implemented.
This commit is contained in:
Alexa
2026-06-02 10:49:35 +03:00
parent 24c8a920e4
commit 7535cd823f
21 changed files with 400 additions and 249 deletions
+31 -24
View File
@@ -4,24 +4,16 @@ import { RequireAuth } from './components/RequireAuth';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard'; import { Dashboard } from './pages/Dashboard';
import { Players } from './pages/Players'; import { Moderation } from './pages/Moderation';
import { Bans } from './pages/Bans'; import { Activity } from './pages/Activity';
import { Reports } from './pages/Reports';
import { Audit } from './pages/Audit';
import { PlayerLogs } from './pages/PlayerLogs';
import { Rooms } from './pages/Rooms'; import { Rooms } from './pages/Rooms';
import { Room } from './pages/Room'; import { Room } from './pages/Room';
import { ImportRoom } from './pages/ImportRoom'; import { ImportRoom } from './pages/ImportRoom';
import { LegacyImportRoom } from './pages/LegacyImportRoom'; import { LegacyImportRoom } from './pages/LegacyImportRoom';
import { Store } from './pages/Store'; import { Store } from './pages/Store';
import { Broadcast } from './pages/Broadcast'; import { Broadcast } from './pages/Broadcast';
import { CommunityBoard } from './pages/CommunityBoard'; import { Content } from './pages/Content';
import { LoadingTips } from './pages/LoadingTips'; import { SettingsHome } from './pages/SettingsHome';
import { Gift } from './pages/Gift';
import { Passwords } from './pages/Passwords';
import { Grants } from './pages/Grants';
import { Settings } from './pages/Settings';
import { SignupCodes } from './pages/SignupCodes';
export default function App() { export default function App() {
return ( return (
@@ -30,11 +22,23 @@ export default function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<RequireAuth><Layout /></RequireAuth>}> <Route element={<RequireAuth><Layout /></RequireAuth>}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="players" element={<Players />} />
<Route path="bans" element={<Bans />} /> {/* Players hub — directory + bans + reports as tabs. The old
<Route path="reports" element={<Reports />} /> standalone routes redirect in with the matching sub-tab
<Route path="audit" element={<Audit />} /> preselected. Per-player ban / grant / gift / password
<Route path="logs" element={<PlayerLogs />} /> actions all live in the row's detail modal. */}
<Route path="players" element={<Moderation />} />
<Route path="bans" element={<Navigate to="/players?tab=bans" replace />} />
<Route path="reports" element={<Navigate to="/players?tab=reports" replace />} />
<Route path="gift" element={<Navigate to="/players" replace />} />
<Route path="passwords" element={<Navigate to="/players" replace />} />
<Route path="grants" element={<Navigate to="/players" replace />} />
{/* Activity hub — audit trail + per-player request logs. */}
<Route path="activity" element={<Activity />} />
<Route path="audit" element={<Navigate to="/activity?tab=audit" replace />} />
<Route path="logs" element={<Navigate to="/activity?tab=logs" replace />} />
<Route path="rooms" element={<Rooms />} /> <Route path="rooms" element={<Rooms />} />
<Route path="rooms/:id" element={<Room />} /> <Route path="rooms/:id" element={<Room />} />
{/* Back-compat: the merge left RR-Originals / Instances / {/* Back-compat: the merge left RR-Originals / Instances /
@@ -48,14 +52,17 @@ export default function App() {
<Route path="import-room" element={<ImportRoom />} /> <Route path="import-room" element={<ImportRoom />} />
<Route path="import-room-legacy" element={<LegacyImportRoom />} /> <Route path="import-room-legacy" element={<LegacyImportRoom />} />
<Route path="store" element={<Store />} /> <Route path="store" element={<Store />} />
<Route path="community" element={<CommunityBoard />} />
<Route path="loading-tips" element={<LoadingTips />} /> {/* Content hub — community board + loading-screen tips. */}
<Route path="content" element={<Content />} />
<Route path="community" element={<Navigate to="/content?tab=community" replace />} />
<Route path="loading-tips" element={<Navigate to="/content?tab=tips" replace />} />
<Route path="broadcast" element={<Broadcast />} /> <Route path="broadcast" element={<Broadcast />} />
<Route path="gift" element={<Gift />} />
<Route path="passwords" element={<Passwords />} /> {/* Settings hub — server toggles + signup codes. */}
<Route path="grants" element={<Grants />} /> <Route path="settings" element={<SettingsHome />} />
<Route path="settings" element={<Settings />} /> <Route path="signup-codes" element={<Navigate to="/settings?tab=signup" replace />} />
<Route path="signup-codes" element={<SignupCodes />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@@ -1,10 +1,16 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { import {
LayoutDashboard, Users, Building2, Ban, Flag, ScrollText, Activity, LayoutDashboard, Users, Building2, Activity,
Megaphone, Upload, ShoppingBag, Gift, KeyRound, Wrench, MessageSquare, Settings, X, Megaphone, ShoppingBag, MessageSquare, Settings, X,
} from './Icons'; } from './Icons';
// Consolidated navigation. Per-player moderation (bans, grants, gifts,
// password resets) lives in the player detail modal; the old Bans /
// Reports / Audit / Player-logs / Community / Loading-tips / Gift /
// Passwords / Signup-codes pages are now tabs under Players, Activity,
// Content, and Settings. Import room is a header action on the Rooms
// page. Old routes still resolve via redirects in App.tsx.
const groups: Array<{ title: string; items: Array<{ to: string; label: string; icon: React.ReactNode; new?: boolean }>}> = [ const groups: Array<{ title: string; items: Array<{ to: string; label: string; icon: React.ReactNode; new?: boolean }>}> = [
{ {
title: 'Overview', title: 'Overview',
@@ -13,38 +19,25 @@ const groups: Array<{ title: string; items: Array<{ to: string; label: string; i
], ],
}, },
{ {
title: 'People', title: 'Moderation',
items: [ items: [
{ to: '/players', label: 'Players', icon: <Users /> }, { to: '/players', label: 'Players', icon: <Users /> },
{ to: '/bans', label: 'Bans', icon: <Ban /> }, { to: '/activity', label: 'Activity', icon: <Activity /> },
{ to: '/reports', label: 'Reports', icon: <Flag /> },
{ to: '/audit', label: 'Audit log', icon: <ScrollText /> },
{ to: '/logs', label: 'Player logs', icon: <Activity /> },
], ],
}, },
{ {
title: 'Content', title: 'Content',
items: [ items: [
// Rooms is the unified view — clicking a row opens /rooms/:id
// which carries the old RR-Originals edit modal, live Photon
// instance list, recent visitors, ownership management, and
// per-room leaderboard channels in one tabbed page.
{ to: '/rooms', label: 'Rooms', icon: <Building2 /> }, { to: '/rooms', label: 'Rooms', icon: <Building2 /> },
{ to: '/import-room', label: 'Import room', icon: <Upload /> }, { to: '/store', label: 'Store', icon: <ShoppingBag /> },
{ to: '/store', label: 'Store catalog', icon: <ShoppingBag /> }, { to: '/content', label: 'Content', icon: <MessageSquare /> },
{ to: '/community', label: 'Community board', icon: <MessageSquare /> },
{ to: '/loading-tips', label: 'Loading screen tips', icon: <MessageSquare /> },
], ],
}, },
{ {
title: 'Tools', title: 'Operations',
items: [ items: [
{ to: '/broadcast', label: 'Broadcast', icon: <Megaphone /> }, { to: '/broadcast', label: 'Broadcast', icon: <Megaphone /> },
{ to: '/gift', label: 'Gift items', icon: <Gift />, new: true }, { to: '/settings', label: 'Settings', icon: <Settings /> },
{ to: '/passwords', label: 'Reset passwords', icon: <KeyRound />, new: true },
{ to: '/grants', label: 'Grant maker pen', icon: <Wrench />, new: true },
{ to: '/signup-codes', label: 'Signup codes', icon: <KeyRound />, new: true },
{ to: '/settings', label: 'Server settings', icon: <Settings />, new: true },
], ],
}, },
]; ];
@@ -0,0 +1,50 @@
import { useSearchParams } from 'react-router-dom';
// Shared horizontal tab strip — the same underlined-button style the
// per-player modal and the old Bans page already used inline, pulled
// out so the consolidated hub pages (Players / Activity / Content /
// Settings) all render their sub-tabs identically.
export function Tabs<T extends string>({
tabs,
active,
onChange,
}: {
tabs: ReadonlyArray<{ key: T; label: string }>;
active: T;
onChange: (key: T) => void;
}) {
return (
<div className="flex border-b border-ink-800 text-sm mb-4 overflow-x-auto">
{tabs.map(t => (
<button
key={t.key}
onClick={() => onChange(t.key)}
className={`px-4 py-2 -mb-px border-b-2 whitespace-nowrap ${
active === t.key
? 'border-brand-500 text-ink-50'
: 'border-transparent text-ink-300 hover:text-ink-100'
}`}
>
{t.label}
</button>
))}
</div>
);
}
// Binds the active hub tab to a `?tab=` query param so the back-compat
// redirects (e.g. /bans → /players?tab=bans) land on the right sub-tab
// and the choice survives a refresh / shared link. The default tab
// drops the param entirely to keep URLs clean.
export function useTabParam<T extends string>(def: T, valid: readonly T[]): [T, (t: T) => void] {
const [params, setParams] = useSearchParams();
const raw = params.get('tab');
const active = (valid as readonly string[]).includes(raw ?? '') ? (raw as T) : def;
const set = (t: T) => {
const next = new URLSearchParams(params);
if (t === def) next.delete('tab');
else next.set('tab', t);
setParams(next, { replace: true });
};
return [active, set];
}
@@ -0,0 +1,25 @@
import { Audit } from './Audit';
import { PlayerLogs } from './PlayerLogs';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// Both log views in one place: the admin audit trail and the per-player
// HTTP request ring buffer. Old /audit and /logs routes redirect here.
export function Activity() {
const [tab, setTab] = useTabParam<'audit' | 'logs'>('audit', ['audit', 'logs']);
return (
<div>
<PageHeader title="Activity" blurb="Admin audit trail and per-player HTTP request logs." />
<Tabs
tabs={[
{ key: 'audit', label: 'Audit log' },
{ key: 'logs', label: 'Player requests' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'audit' && <Audit embedded />}
{tab === 'logs' && <PlayerLogs embedded />}
</div>
);
}
+16 -8
View File
@@ -6,16 +6,12 @@ import { Empty } from '../components/Empty';
import { absoluteTime, clip, relativeTime } from '../lib/format'; import { absoluteTime, clip, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons'; import { RefreshCw } from '../components/Icons';
export function Audit() { export function Audit({ embedded }: { embedded?: boolean } = {}) {
const [take, setTake] = useState(200); const [take, setTake] = useState(200);
const { data, loading, error, refresh } = useApi<AuditEntry[]>(`/audit?take=${take}`); const { data, loading, error, refresh } = useApi<AuditEntry[]>(`/audit?take=${take}`);
return ( const controls = (
<div> <>
<PageHeader
title="Audit log"
blurb="Every admin action with actor, target, and reason. Newest first."
actions={<>
<select value={take} onChange={e => setTake(parseInt(e.target.value))} className="input w-24 text-xs !py-1.5"> <select value={take} onChange={e => setTake(parseInt(e.target.value))} className="input w-24 text-xs !py-1.5">
<option value={100}>last 100</option> <option value={100}>last 100</option>
<option value={200}>last 200</option> <option value={200}>last 200</option>
@@ -24,8 +20,20 @@ export function Audit() {
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}> <button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh <RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button> </button>
</>} </>
);
return (
<div>
{embedded ? (
<div className="flex justify-end gap-2 mb-3">{controls}</div>
) : (
<PageHeader
title="Audit log"
blurb="Every admin action with actor, target, and reason. Newest first."
actions={controls}
/> />
)}
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>} {error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>}
+3 -1
View File
@@ -8,14 +8,16 @@ import { Empty } from '../components/Empty';
import { absoluteTime, relativeTime } from '../lib/format'; import { absoluteTime, relativeTime } from '../lib/format';
import { RefreshCw, Trash } from '../components/Icons'; import { RefreshCw, Trash } from '../components/Icons';
export function Bans() { export function Bans({ embedded }: { embedded?: boolean } = {}) {
const [tab, setTab] = useState<'players' | 'ip'>('players'); const [tab, setTab] = useState<'players' | 'ip'>('players');
return ( return (
<div> <div>
{!embedded && (
<PageHeader <PageHeader
title="Bans" title="Bans"
blurb="Player bans and IP-level bans, all in one place." blurb="Player bans and IP-level bans, all in one place."
/> />
)}
<div className="flex border-b border-ink-800 text-sm mb-4"> <div className="flex border-b border-ink-800 text-sm mb-4">
{(['players', 'ip'] as const).map(t => ( {(['players', 'ip'] as const).map(t => (
<button <button
@@ -6,7 +6,7 @@ import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { Plus, Trash } from '../components/Icons'; import { Plus, Trash } from '../components/Icons';
export function CommunityBoard() { export function CommunityBoard({ embedded }: { embedded?: boolean } = {}) {
const { data: roomList } = useApi<Room[]>('/rooms'); const { data: roomList } = useApi<Room[]>('/rooms');
const { data: players } = useApi<Player[]>('/players?take=500'); const { data: players } = useApi<Player[]>('/players?take=500');
const [state, setState] = useState<CommunityBoardState | null>(null); const [state, setState] = useState<CommunityBoardState | null>(null);
@@ -37,13 +37,19 @@ export function CommunityBoard() {
} }
}; };
const saveBtn = <button onClick={save} disabled={busy} className="btn-primary text-xs">{busy ? 'Saving…' : 'Save all'}</button>;
return ( return (
<div> <div>
{embedded ? (
<div className="flex justify-end mb-3">{saveBtn}</div>
) : (
<PageHeader <PageHeader
title="Community board" title="Community board"
blurb="The dorm community panel the watch fetches from /api/communityboard/v1/current. Edits go live instantly." blurb="The dorm community panel the watch fetches from /api/communityboard/v1/current. Edits go live instantly."
actions={<button onClick={save} disabled={busy} className="btn-primary text-xs">{busy ? 'Saving…' : 'Save all'}</button>} actions={saveBtn}
/> />
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Announcement state={state} setState={setState} /> <Announcement state={state} setState={setState} />
@@ -0,0 +1,26 @@
import { CommunityBoard } from './CommunityBoard';
import { LoadingTips } from './LoadingTips';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// The two player-facing content surfaces the watch pulls on dorm load —
// the community board and the loading-screen tips — share one page.
// Old /community and /loading-tips routes redirect here.
export function Content() {
const [tab, setTab] = useTabParam<'community' | 'tips'>('community', ['community', 'tips']);
return (
<div>
<PageHeader title="Content" blurb="Player-facing content shown on dorm load: the community board and loading-screen tips." />
<Tabs
tabs={[
{ key: 'community', label: 'Community board' },
{ key: 'tips', label: 'Loading tips' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'community' && <CommunityBoard embedded />}
{tab === 'tips' && <LoadingTips embedded />}
</div>
);
}
+11 -22
View File
@@ -1,8 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { api, get } from '../lib/api'; import { api, get } from '../lib/api';
import type { Player } from '../lib/types';
import { useApi } from '../lib/useApi'; import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { Confirm } from '../components/Confirm'; import { Confirm } from '../components/Confirm';
import { RefreshCw, Search, Trash } from '../components/Icons'; import { RefreshCw, Search, Trash } from '../components/Icons';
@@ -41,10 +39,11 @@ interface AvatarItem {
// and the consume endpoint writes the avatar item into their inventory. // and the consume endpoint writes the avatar item into their inventory.
// This is the only flow that triggers the popup — directly poking // This is the only flow that triggers the popup — directly poking
// InventoryJson skips it entirely. // InventoryJson skips it entirely.
export function Gift() { // Per-player gift composer, rendered as the "Gift" tab of the player
const { data: players } = useApi<Player[]>('/players?take=500'); // detail modal. The recipient is fixed to the open player, so there's
const [playerId, setPlayerId] = useState<number | null>(null); // no picker and no page chrome — just the reward builder, presentation
// options, and that player's pending-gift inbox.
export function GiftPanel({ playerId }: { playerId: number }) {
const [includeItem, setIncludeItem] = useState(true); const [includeItem, setIncludeItem] = useState(true);
const [includeCurrency, setIncludeCurrency] = useState(false); const [includeCurrency, setIncludeCurrency] = useState(false);
const [includeXp, setIncludeXp] = useState(false); const [includeXp, setIncludeXp] = useState(false);
@@ -66,7 +65,6 @@ export function Gift() {
const toast = useToast(); const toast = useToast();
const send = async () => { const send = async () => {
if (!playerId) return toast.push('Pick a recipient first', 'error');
if (!includeItem && !includeCurrency && !includeXp) { if (!includeItem && !includeCurrency && !includeXp) {
return toast.push('Pick at least one reward (item / currency / xp)', 'error'); return toast.push('Pick at least one reward (item / currency / xp)', 'error');
} }
@@ -101,23 +99,14 @@ export function Gift() {
return ( return (
<div> <div>
<PageHeader <p className="text-xs text-ink-400 mb-4">
title="Send a gift" Drops a wrapped gift box on this player's HUD — they tap to open it and the rewards land in their inventory / wallet.
blurb="Drops a wrapped gift box on the recipient's HUD — they tap to open it and the rewards land in their inventory / wallet." </p>
/>
{playerId !== null && <PendingGifts playerId={playerId} refreshKey={pendingRefreshKey} />} <PendingGifts playerId={playerId} refreshKey={pendingRefreshKey} />
<div className="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-4 max-w-5xl"> <div className="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-4">
<div className="card !p-5 space-y-4"> <div className="card !p-5 space-y-4">
<label className="flex flex-col gap-1">
<span className="label">Recipient</span>
<select value={playerId ?? ''} onChange={e => setPlayerId(e.target.value ? parseInt(e.target.value) : null)} className="input">
<option value="">— pick a player —</option>
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
</select>
</label>
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center gap-2 text-sm text-ink-100 font-medium"> <label className="flex items-center gap-2 text-sm text-ink-100 font-medium">
<input type="checkbox" checked={includeItem} onChange={e => setIncludeItem(e.target.checked)} className="size-4 accent-brand-500" /> <input type="checkbox" checked={includeItem} onChange={e => setIncludeItem(e.target.checked)} className="size-4 accent-brand-500" />
@@ -198,7 +187,7 @@ export function Gift() {
<span className="label">Message (shown on the open card)</span> <span className="label">Message (shown on the open card)</span>
<textarea value={message} onChange={e => setMessage(e.target.value)} rows={3} className="input" /> <textarea value={message} onChange={e => setMessage(e.target.value)} rows={3} className="input" />
</label> </label>
<button onClick={send} disabled={busy || !playerId} className="btn-primary w-full text-xs"> <button onClick={send} disabled={busy} className="btn-primary w-full text-xs">
{busy ? 'Sending…' : 'Send gift'} {busy ? 'Sending…' : 'Send gift'}
</button> </button>
<div className="text-[11px] text-ink-500"> <div className="text-[11px] text-ink-500">
@@ -1,36 +0,0 @@
import { PageHeader } from '../components/PageHeader';
// Maker-pen-anywhere needs server-side integration with CdnController
// (substitute the room blob for flagged players so they get the
// all-permissions PersistedRoomData) AND a PlayerEntity.HasGlobalMakerPen
// column with an EF migration. Wiring is non-trivial enough to keep this
// page as a clearly-labeled placeholder until that lands — saves shipping
// a button that does nothing.
export function Grants() {
return (
<div>
<PageHeader
title="Grant maker pen"
blurb="Flag players who should receive maker-pen permissions in every room they load."
/>
<div className="card !p-6 max-w-2xl space-y-3 text-sm text-ink-300">
<div className="rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
<strong className="text-warn">Not yet wired up.</strong> The endpoint and CDN integration land in a follow-up commit.
</div>
<p>
The plan: add <span className="font-mono text-xs text-ink-100">HasGlobalMakerPen</span> to
<span className="font-mono text-xs text-ink-100"> PlayerEntity</span>, intercept room blob
fetches in <span className="font-mono text-xs text-ink-100">CdnController</span> for flagged players,
and substitute the all-permissions default blob that
<span className="font-mono text-xs text-ink-100"> RoomDataBlobService</span> already produces.
Result: the player loads any room with full Maker Pen / invention spawn permissions, regardless of the
room's owner-defined roles.
</p>
<p className="text-xs text-ink-400">
Until then, use the Player → Grants tab to bestow per-item inventory grants (Maker Pen is granted via
inventory in some flows).
</p>
</div>
</div>
);
}
@@ -29,7 +29,7 @@ function blankTip(): LoadingScreenTip {
}; };
} }
export function LoadingTips() { export function LoadingTips({ embedded }: { embedded?: boolean } = {}) {
const [tips, setTips] = useState<LoadingScreenTip[] | null>(null); const [tips, setTips] = useState<LoadingScreenTip[] | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const toast = useToast(); const toast = useToast();
@@ -100,13 +100,19 @@ export function LoadingTips() {
} }
}; };
const addBtn = <button onClick={addNew} className="btn-primary text-xs"><Plus /> Add tip</button>;
return ( return (
<div> <div>
{embedded ? (
<div className="flex justify-end mb-3">{addBtn}</div>
) : (
<PageHeader <PageHeader
title="Loading screen tips" title="Loading screen tips"
blurb="The list the watch fetches from cdn.localhost/config/LoadingScreenTipData on every dorm load. Edit, upload an image, or scope a tip to specific rooms — saves are live immediately." blurb="The list the watch fetches from cdn.localhost/config/LoadingScreenTipData on every dorm load. Edit, upload an image, or scope a tip to specific rooms — saves are live immediately."
actions={<button onClick={addNew} className="btn-primary text-xs"><Plus /> Add tip</button>} actions={addBtn}
/> />
)}
{err && ( {err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4"> <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">
@@ -0,0 +1,31 @@
import { Players } from './Players';
import { Bans } from './Bans';
import { Reports } from './Reports';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// Unified moderation hub. The account directory (with its per-player
// detail / ban / grant / gift / password modal), the active-ban list,
// and the open-report queue were three separate sidebar entries — they
// describe one workflow, so they're tabs of one page now. The old
// /bans and /reports routes redirect here with ?tab= preselected.
export function Moderation() {
const [tab, setTab] = useTabParam<'directory' | 'bans' | 'reports'>('directory', ['directory', 'bans', 'reports']);
return (
<div>
<PageHeader title="Players" blurb="Search and moderate accounts, review active bans, and triage open reports." />
<Tabs
tabs={[
{ key: 'directory', label: 'Directory' },
{ key: 'bans', label: 'Bans' },
{ key: 'reports', label: 'Reports' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'directory' && <Players embedded />}
{tab === 'bans' && <Bans embedded />}
{tab === 'reports' && <Reports embedded />}
</div>
);
}
+14 -30
View File
@@ -1,13 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { api } from '../lib/api'; import { api } from '../lib/api';
import type { Player } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
export function Passwords() { // Admin password reset, rendered as a card inside the player detail
const { data: players } = useApi<Player[]>('/players?take=500'); // modal's Profile tab. The target account is the open player, so there's
const [playerId, setPlayerId] = useState<number | null>(null); // no picker — just set-and-confirm. Issues a Logout push so any active
// sessions on the old password get dropped.
export function PasswordResetCard({ playerId, username }: { playerId: number; username: string }) {
const [pw, setPw] = useState(''); const [pw, setPw] = useState('');
const [pw2, setPw2] = useState(''); const [pw2, setPw2] = useState('');
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
@@ -27,11 +26,9 @@ export function Passwords() {
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!playerId) return;
if (pw !== pw2) return toast.push("Passwords don't match", 'error'); if (pw !== pw2) return toast.push("Passwords don't match", 'error');
if (pw.length < 8) return toast.push('Password must be at least 8 characters', 'error'); if (pw.length < 8) return toast.push('Password must be at least 8 characters', 'error');
const player = players?.find(p => p.id === playerId); if (!confirm(`Reset password for @${username}? This forces a logout on every active session.`)) return;
if (!confirm(`Reset password for @${player?.username ?? `#${playerId}`}? This forces a logout on every active session.`)) return;
setBusy(true); setBusy(true);
try { try {
await api(`/players/${playerId}/password`, { await api(`/players/${playerId}/password`, {
@@ -48,20 +45,14 @@ export function Passwords() {
}; };
return ( return (
<form onSubmit={submit} className="card !p-4 md:col-span-2 space-y-3">
<div> <div>
<PageHeader <h3 className="text-sm font-semibold text-ink-50 mb-1">Reset password</h3>
title="Reset passwords" <p className="text-xs text-ink-400">
blurb="Admin-set a new password for any account. Issues a Logout push so any active sessions on the old password get dropped." Admin-set a new password. The plaintext is hashed with BCrypt before write — we never store it, and the audit log
/> records the action but not the password. Issues a Logout push that drops any session still on the old password.
</p>
<form onSubmit={submit} className="card !p-5 max-w-xl space-y-4"> </div>
<label className="flex flex-col gap-1">
<span className="label">Account</span>
<select value={playerId ?? ''} onChange={e => setPlayerId(e.target.value ? parseInt(e.target.value) : null)} required className="input">
<option value="">— pick an account —</option>
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
</select>
</label>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="label">New password</span> <span className="label">New password</span>
@@ -71,7 +62,6 @@ export function Passwords() {
value={pw} value={pw}
onChange={e => setPw(e.target.value)} onChange={e => setPw(e.target.value)}
minLength={8} minLength={8}
required
autoComplete="new-password" autoComplete="new-password"
className="input flex-1 font-mono" className="input flex-1 font-mono"
/> />
@@ -87,23 +77,17 @@ export function Passwords() {
value={pw2} value={pw2}
onChange={e => setPw2(e.target.value)} onChange={e => setPw2(e.target.value)}
minLength={8} minLength={8}
required
autoComplete="new-password" autoComplete="new-password"
className="input font-mono" className="input font-mono"
/> />
</label> </label>
<div className="rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
The plaintext is hashed with BCrypt before write — we never store it. The audit log records the action but not the password.
</div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={() => { setPw(''); setPw2(''); setShow(false); }} className="btn-ghost text-xs" disabled={busy}>Clear</button> <button type="button" onClick={() => { setPw(''); setPw2(''); setShow(false); }} className="btn-ghost text-xs" disabled={busy}>Clear</button>
<button className="btn-primary text-xs" disabled={busy || !playerId || pw.length < 8 || pw !== pw2}> <button className="btn-primary text-xs" disabled={busy || pw.length < 8 || pw !== pw2}>
{busy ? 'Updating…' : 'Update password'} {busy ? 'Updating…' : 'Update password'}
</button> </button>
</div> </div>
</form> </form>
</div>
); );
} }
@@ -7,7 +7,7 @@ import { Empty } from '../components/Empty';
import { absoluteTime, clip, relativeTime } from '../lib/format'; import { absoluteTime, clip, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons'; import { RefreshCw } from '../components/Icons';
export function PlayerLogs() { export function PlayerLogs({ embedded }: { embedded?: boolean } = {}) {
const { data: players } = useApi<Player[]>('/players?take=500'); const { data: players } = useApi<Player[]>('/players?take=500');
const [pid, setPid] = useState<number | null>(null); const [pid, setPid] = useState<number | null>(null);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
@@ -49,10 +49,12 @@ export function PlayerLogs() {
return ( return (
<div> <div>
{!embedded && (
<PageHeader <PageHeader
title="Player request logs" title="Player request logs"
blurb="Last few hundred HTTP calls per player, pulled from the Redis ring buffer. Useful when a player reports something weird." blurb="Last few hundred HTTP calls per player, pulled from the Redis ring buffer. Useful when a player reports something weird."
/> />
)}
<div className="card !p-4 mb-4 grid grid-cols-1 md:grid-cols-[2fr,1fr,120px,auto,auto] gap-2 items-end"> <div className="card !p-4 mb-4 grid grid-cols-1 md:grid-cols-[2fr,1fr,120px,auto,auto] gap-2 items-end">
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
+21 -10
View File
@@ -10,8 +10,10 @@ import { useToast } from '../components/Toast';
import { Empty } from '../components/Empty'; import { Empty } from '../components/Empty';
import { PlayerAvatar } from '../components/PlayerAvatar'; import { PlayerAvatar } from '../components/PlayerAvatar';
import { RefreshCw, Search } from '../components/Icons'; import { RefreshCw, Search } from '../components/Icons';
import { GiftPanel } from './Gift';
import { PasswordResetCard } from './Passwords';
export function Players() { export function Players({ embedded }: { embedded?: boolean } = {}) {
const [params] = useSearchParams(); const [params] = useSearchParams();
const [query, setQuery] = useState(params.get('query') ?? ''); const [query, setQuery] = useState(params.get('query') ?? '');
const [rows, setRows] = useState<Player[] | null>(null); const [rows, setRows] = useState<Player[] | null>(null);
@@ -45,18 +47,24 @@ export function Players() {
if (next !== query) setQuery(next); if (next !== query) setQuery(next);
}, [params, query]); }, [params, query]);
return ( const refreshBtn = (
<div>
<PageHeader
title="Players"
blurb="Search and moderate every account in the database."
actions={
<button onClick={() => search(query)} className="btn-secondary text-xs" disabled={loading}> <button onClick={() => search(query)} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> <RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh Refresh
</button> </button>
} );
return (
<div>
{embedded ? (
<div className="flex justify-end mb-3">{refreshBtn}</div>
) : (
<PageHeader
title="Players"
blurb="Search and moderate every account in the database."
actions={refreshBtn}
/> />
)}
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<div className="border-b border-ink-800 p-3 flex items-center gap-2"> <div className="border-b border-ink-800 p-3 flex items-center gap-2">
@@ -153,7 +161,7 @@ export function Players() {
function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => void; onChanged: () => void }) { function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => void; onChanged: () => void }) {
const [data, setData] = useState<PlayerDetail | null>(null); const [data, setData] = useState<PlayerDetail | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [tab, setTab] = useState<'overview' | 'mod' | 'grants' | 'profile'>('overview'); const [tab, setTab] = useState<'overview' | 'mod' | 'grants' | 'gift' | 'profile'>('overview');
const reload = () => { const reload = () => {
setErr(null); setErr(null);
@@ -196,7 +204,7 @@ function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => v
</div> </div>
<div className="flex border-b border-ink-800 text-sm"> <div className="flex border-b border-ink-800 text-sm">
{(['overview', 'mod', 'grants', 'profile'] as const).map(t => ( {(['overview', 'mod', 'grants', 'gift', 'profile'] as const).map(t => (
<button <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
@@ -210,6 +218,7 @@ function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => v
{tab === 'overview' && <OverviewTab data={data} />} {tab === 'overview' && <OverviewTab data={data} />}
{tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />} {tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />}
{tab === 'grants' && <GrantsTab data={data} onChanged={reload} />} {tab === 'grants' && <GrantsTab data={data} onChanged={reload} />}
{tab === 'gift' && <GiftPanel playerId={data.id} />}
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} />} {tab === 'profile' && <ProfileTab data={data} onChanged={reload} />}
</div> </div>
)} )}
@@ -576,6 +585,8 @@ function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () =>
className="btn-danger text-xs" className="btn-danger text-xs"
>Reset to starter</button> >Reset to starter</button>
</div> </div>
<PasswordResetCard playerId={data.id} username={data.username} />
</div> </div>
); );
} }
+14 -8
View File
@@ -9,21 +9,27 @@ import { useToast } from '../components/Toast';
import { absoluteTime, relativeTime } from '../lib/format'; import { absoluteTime, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons'; import { RefreshCw } from '../components/Icons';
export function Reports() { export function Reports({ embedded }: { embedded?: boolean } = {}) {
const { data, loading, error, refresh } = useApi<Report[]>('/reports?take=100'); const { data, loading, error, refresh } = useApi<Report[]>('/reports?take=100');
const [resolving, setResolving] = useState<Report | null>(null); const [resolving, setResolving] = useState<Report | null>(null);
return ( const refreshBtn = (
<div>
<PageHeader
title="Open reports"
blurb="Oldest first. Resolving a report only closes the ticket — issue the actual moderation action separately."
actions={
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}> <button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh <RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button> </button>
} );
return (
<div>
{embedded ? (
<div className="flex justify-end mb-3">{refreshBtn}</div>
) : (
<PageHeader
title="Open reports"
blurb="Oldest first. Resolving a report only closes the ticket — issue the actual moderation action separately."
actions={refreshBtn}
/> />
)}
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>} {error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>}
+4 -1
View File
@@ -8,7 +8,7 @@ import { Empty } from '../components/Empty';
import { Modal } from '../components/Modal'; import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { Confirm } from '../components/Confirm'; import { Confirm } from '../components/Confirm';
import { RefreshCw, Trash } from '../components/Icons'; import { RefreshCw, Trash, Upload } from '../components/Icons';
export function Rooms() { export function Rooms() {
const { data, loading, error, refresh } = useApi<Room[]>('/rooms'); const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
@@ -55,6 +55,9 @@ export function Rooms() {
title="Rooms" title="Rooms"
blurb="Every room in the DB. Archive (trash icon) is soft and reversible. Purge is a hard wipe — drops scene rows + per-room blobs + thumbnail and requires typing the room name twice." blurb="Every room in the DB. Archive (trash icon) is soft and reversible. Purge is a hard wipe — drops scene rows + per-room blobs + thumbnail and requires typing the room name twice."
actions={<> actions={<>
<Link to="/import-room" className="btn-primary text-xs">
<Upload /> Import room
</Link>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}> <button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh <RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button> </button>
+14 -8
View File
@@ -54,7 +54,7 @@ interface RewardOptions {
equipment: RewardOption[]; equipment: RewardOption[];
} }
export function Settings() { export function Settings({ embedded }: { embedded?: boolean } = {}) {
const [settings, setSettings] = useState<ServerSettings | null>(null); const [settings, setSettings] = useState<ServerSettings | null>(null);
const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null); const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null);
const [rewardOptions, setRewardOptions] = useState<RewardOptions>({ const [rewardOptions, setRewardOptions] = useState<RewardOptions>({
@@ -252,18 +252,24 @@ export function Settings() {
} }
}; };
return ( const refreshBtn = (
<div>
<PageHeader
title="Server settings"
blurb="Runtime toggles applied across every replica without a redeploy."
actions={
<button onClick={load} className="btn-secondary text-xs" disabled={busy}> <button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} /> <RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh Refresh
</button> </button>
} );
return (
<div>
{embedded ? (
<div className="flex justify-end mb-3">{refreshBtn}</div>
) : (
<PageHeader
title="Server settings"
blurb="Runtime toggles applied across every replica without a redeploy."
actions={refreshBtn}
/> />
)}
{err && ( {err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div> <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
@@ -0,0 +1,26 @@
import { Settings } from './Settings';
import { SignupCodes } from './SignupCodes';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// Server-wide configuration: runtime toggles plus the single-use signup
// invite codes (an access-control setting in its own right). Old
// /signup-codes route redirects here with ?tab=signup.
export function SettingsHome() {
const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']);
return (
<div>
<PageHeader title="Settings" blurb="Runtime server toggles and single-use signup invite codes." />
<Tabs
tabs={[
{ key: 'server', label: 'Server' },
{ key: 'signup', label: 'Signup codes' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'server' && <Settings embedded />}
{tab === 'signup' && <SignupCodes embedded />}
</div>
);
}
@@ -24,7 +24,7 @@ const STATUS_BADGE: Record<SignupCode['status'], string> = {
expired: 'badge-neutral', expired: 'badge-neutral',
}; };
export function SignupCodes() { export function SignupCodes({ embedded }: { embedded?: boolean } = {}) {
const [codes, setCodes] = useState<SignupCode[] | null>(null); const [codes, setCodes] = useState<SignupCode[] | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [descriptor, setDescriptor] = useState(''); const [descriptor, setDescriptor] = useState('');
@@ -84,18 +84,24 @@ export function SignupCodes() {
} }
}; };
return ( const refreshBtn = (
<div>
<PageHeader
title="Signup codes"
blurb="Single-use invite codes. Hand one to a player; they redeem it on the site's /join page to create an account while signups are disabled."
actions={
<button onClick={load} className="btn-secondary text-xs"> <button onClick={load} className="btn-secondary text-xs">
<RefreshCw /> <RefreshCw />
Refresh Refresh
</button> </button>
} );
return (
<div>
{embedded ? (
<div className="flex justify-end mb-3">{refreshBtn}</div>
) : (
<PageHeader
title="Signup codes"
blurb="Single-use invite codes. Hand one to a player; they redeem it on the site's /join page to create an account while signups are disabled."
actions={refreshBtn}
/> />
)}
{err && ( {err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div> <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
+2 -2
View File
@@ -141,9 +141,9 @@ so both providers pick them up on existing DBs without a new migration
### Signup codes ### Signup codes
When account creation is disabled (admin **Server settings**), the only When account creation is disabled (admin **Settings → Server**), the only
way in is an admin-issued single-use **signup code**: generate one in the way in is an admin-issued single-use **signup code**: generate one in the
admin panel's **Signup codes** page (with a descriptor + optional expiry), admin panel's **Settings → Signup codes** tab (with a descriptor + optional expiry),
hand it to the player, and they redeem it on the site's **`/join`** page — hand it to the player, and they redeem it on the site's **`/join`** page —
which creates their account bound to the device their game client which creates their account bound to the device their game client
reported, so the next launch logs straight in. reported, so the next launch logs straight in.