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 { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard';
import { Players } from './pages/Players';
import { Bans } from './pages/Bans';
import { Reports } from './pages/Reports';
import { Audit } from './pages/Audit';
import { PlayerLogs } from './pages/PlayerLogs';
import { Moderation } from './pages/Moderation';
import { Activity } from './pages/Activity';
import { Rooms } from './pages/Rooms';
import { Room } from './pages/Room';
import { ImportRoom } from './pages/ImportRoom';
import { LegacyImportRoom } from './pages/LegacyImportRoom';
import { Store } from './pages/Store';
import { Broadcast } from './pages/Broadcast';
import { CommunityBoard } from './pages/CommunityBoard';
import { LoadingTips } from './pages/LoadingTips';
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';
import { Content } from './pages/Content';
import { SettingsHome } from './pages/SettingsHome';
export default function App() {
return (
@@ -30,11 +22,23 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route element={<RequireAuth><Layout /></RequireAuth>}>
<Route index element={<Dashboard />} />
<Route path="players" element={<Players />} />
<Route path="bans" element={<Bans />} />
<Route path="reports" element={<Reports />} />
<Route path="audit" element={<Audit />} />
<Route path="logs" element={<PlayerLogs />} />
{/* Players hub — directory + bans + reports as tabs. The old
standalone routes redirect in with the matching sub-tab
preselected. Per-player ban / grant / gift / password
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/:id" element={<Room />} />
{/* 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-legacy" element={<LegacyImportRoom />} />
<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="gift" element={<Gift />} />
<Route path="passwords" element={<Passwords />} />
<Route path="grants" element={<Grants />} />
<Route path="settings" element={<Settings />} />
<Route path="signup-codes" element={<SignupCodes />} />
{/* Settings hub — server toggles + signup codes. */}
<Route path="settings" element={<SettingsHome />} />
<Route path="signup-codes" element={<Navigate to="/settings?tab=signup" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -1,10 +1,16 @@
import { useEffect } from 'react';
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard, Users, Building2, Ban, Flag, ScrollText, Activity,
Megaphone, Upload, ShoppingBag, Gift, KeyRound, Wrench, MessageSquare, Settings, X,
LayoutDashboard, Users, Building2, Activity,
Megaphone, ShoppingBag, MessageSquare, Settings, X,
} 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 }>}> = [
{
title: 'Overview',
@@ -13,38 +19,25 @@ const groups: Array<{ title: string; items: Array<{ to: string; label: string; i
],
},
{
title: 'People',
title: 'Moderation',
items: [
{ to: '/players', label: 'Players', icon: <Users /> },
{ to: '/bans', label: 'Bans', icon: <Ban /> },
{ to: '/reports', label: 'Reports', icon: <Flag /> },
{ to: '/audit', label: 'Audit log', icon: <ScrollText /> },
{ to: '/logs', label: 'Player logs', icon: <Activity /> },
{ to: '/players', label: 'Players', icon: <Users /> },
{ to: '/activity', label: 'Activity', icon: <Activity /> },
],
},
{
title: 'Content',
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: '/import-room', label: 'Import room', icon: <Upload /> },
{ to: '/store', label: 'Store catalog', icon: <ShoppingBag /> },
{ to: '/community', label: 'Community board', icon: <MessageSquare /> },
{ to: '/loading-tips', label: 'Loading screen tips', icon: <MessageSquare /> },
{ to: '/rooms', label: 'Rooms', icon: <Building2 /> },
{ to: '/store', label: 'Store', icon: <ShoppingBag /> },
{ to: '/content', label: 'Content', icon: <MessageSquare /> },
],
},
{
title: 'Tools',
title: 'Operations',
items: [
{ to: '/broadcast', label: 'Broadcast', icon: <Megaphone /> },
{ to: '/gift', label: 'Gift items', icon: <Gift />, new: true },
{ 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 },
{ to: '/broadcast', label: 'Broadcast', icon: <Megaphone /> },
{ to: '/settings', label: 'Settings', icon: <Settings /> },
],
},
];
@@ -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>
);
}
+23 -15
View File
@@ -6,26 +6,34 @@ import { Empty } from '../components/Empty';
import { absoluteTime, clip, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons';
export function Audit() {
export function Audit({ embedded }: { embedded?: boolean } = {}) {
const [take, setTake] = useState(200);
const { data, loading, error, refresh } = useApi<AuditEntry[]>(`/audit?take=${take}`);
const controls = (
<>
<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={200}>last 200</option>
<option value={500}>last 500</option>
</select>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</>
);
return (
<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">
<option value={100}>last 100</option>
<option value={200}>last 200</option>
<option value={500}>last 500</option>
</select>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</>}
/>
{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>}
+7 -5
View File
@@ -8,14 +8,16 @@ import { Empty } from '../components/Empty';
import { absoluteTime, relativeTime } from '../lib/format';
import { RefreshCw, Trash } from '../components/Icons';
export function Bans() {
export function Bans({ embedded }: { embedded?: boolean } = {}) {
const [tab, setTab] = useState<'players' | 'ip'>('players');
return (
<div>
<PageHeader
title="Bans"
blurb="Player bans and IP-level bans, all in one place."
/>
{!embedded && (
<PageHeader
title="Bans"
blurb="Player bans and IP-level bans, all in one place."
/>
)}
<div className="flex border-b border-ink-800 text-sm mb-4">
{(['players', 'ip'] as const).map(t => (
<button
@@ -6,7 +6,7 @@ import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Plus, Trash } from '../components/Icons';
export function CommunityBoard() {
export function CommunityBoard({ embedded }: { embedded?: boolean } = {}) {
const { data: roomList } = useApi<Room[]>('/rooms');
const { data: players } = useApi<Player[]>('/players?take=500');
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 (
<div>
<PageHeader
title="Community board"
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>}
/>
{embedded ? (
<div className="flex justify-end mb-3">{saveBtn}</div>
) : (
<PageHeader
title="Community board"
blurb="The dorm community panel the watch fetches from /api/communityboard/v1/current. Edits go live instantly."
actions={saveBtn}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<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 { api, get } 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 { Confirm } from '../components/Confirm';
import { RefreshCw, Search, Trash } from '../components/Icons';
@@ -41,10 +39,11 @@ interface AvatarItem {
// and the consume endpoint writes the avatar item into their inventory.
// This is the only flow that triggers the popup — directly poking
// InventoryJson skips it entirely.
export function Gift() {
const { data: players } = useApi<Player[]>('/players?take=500');
const [playerId, setPlayerId] = useState<number | null>(null);
// Per-player gift composer, rendered as the "Gift" tab of the player
// detail modal. The recipient is fixed to the open player, so there's
// 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 [includeCurrency, setIncludeCurrency] = useState(false);
const [includeXp, setIncludeXp] = useState(false);
@@ -66,7 +65,6 @@ export function Gift() {
const toast = useToast();
const send = async () => {
if (!playerId) return toast.push('Pick a recipient first', 'error');
if (!includeItem && !includeCurrency && !includeXp) {
return toast.push('Pick at least one reward (item / currency / xp)', 'error');
}
@@ -101,23 +99,14 @@ export function Gift() {
return (
<div>
<PageHeader
title="Send a gift"
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 className="text-xs text-ink-400 mb-4">
Drops a wrapped gift box on this player'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">
<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">
<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" />
@@ -198,7 +187,7 @@ export function Gift() {
<span className="label">Message (shown on the open card)</span>
<textarea value={message} onChange={e => setMessage(e.target.value)} rows={3} className="input" />
</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'}
</button>
<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 [err, setErr] = useState<string | null>(null);
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 (
<div>
<PageHeader
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."
actions={<button onClick={addNew} className="btn-primary text-xs"><Plus /> Add tip</button>}
/>
{embedded ? (
<div className="flex justify-end mb-3">{addBtn}</div>
) : (
<PageHeader
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."
actions={addBtn}
/>
)}
{err && (
<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>
);
}
+42 -58
View File
@@ -1,13 +1,12 @@
import { useState } from 'react';
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';
export function Passwords() {
const { data: players } = useApi<Player[]>('/players?take=500');
const [playerId, setPlayerId] = useState<number | null>(null);
// Admin password reset, rendered as a card inside the player detail
// modal's Profile tab. The target account is the open player, so there's
// 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 [pw2, setPw2] = useState('');
const [show, setShow] = useState(false);
@@ -27,11 +26,9 @@ export function Passwords() {
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!playerId) return;
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');
const player = players?.find(p => p.id === playerId);
if (!confirm(`Reset password for @${player?.username ?? `#${playerId}`}? This forces a logout on every active session.`)) return;
if (!confirm(`Reset password for @${username}? This forces a logout on every active session.`)) return;
setBusy(true);
try {
await api(`/players/${playerId}/password`, {
@@ -48,62 +45,49 @@ export function Passwords() {
};
return (
<div>
<PageHeader
title="Reset passwords"
blurb="Admin-set a new password for any account. Issues a Logout push so any active sessions on the old password get dropped."
/>
<form onSubmit={submit} className="card !p-4 md:col-span-2 space-y-3">
<div>
<h3 className="text-sm font-semibold text-ink-50 mb-1">Reset password</h3>
<p className="text-xs text-ink-400">
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>
</div>
<form onSubmit={submit} className="card !p-5 max-w-xl space-y-4">
<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">
<span className="label">New password</span>
<div className="flex gap-2">
<input
type={show ? 'text' : 'password'}
value={pw}
onChange={e => setPw(e.target.value)}
minLength={8}
required
autoComplete="new-password"
className="input flex-1 font-mono"
/>
<button type="button" onClick={() => setShow(s => !s)} className="btn-ghost text-xs">{show ? 'Hide' : 'Show'}</button>
<button type="button" onClick={random} className="btn-secondary text-xs">Random</button>
</div>
</label>
<label className="flex flex-col gap-1">
<span className="label">Confirm new password</span>
<label className="flex flex-col gap-1">
<span className="label">New password</span>
<div className="flex gap-2">
<input
type={show ? 'text' : 'password'}
value={pw2}
onChange={e => setPw2(e.target.value)}
value={pw}
onChange={e => setPw(e.target.value)}
minLength={8}
required
autoComplete="new-password"
className="input font-mono"
className="input flex-1 font-mono"
/>
</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.
<button type="button" onClick={() => setShow(s => !s)} className="btn-ghost text-xs">{show ? 'Hide' : 'Show'}</button>
<button type="button" onClick={random} className="btn-secondary text-xs">Random</button>
</div>
</label>
<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 className="btn-primary text-xs" disabled={busy || !playerId || pw.length < 8 || pw !== pw2}>
{busy ? 'Updating…' : 'Update password'}
</button>
</div>
</form>
</div>
<label className="flex flex-col gap-1">
<span className="label">Confirm new password</span>
<input
type={show ? 'text' : 'password'}
value={pw2}
onChange={e => setPw2(e.target.value)}
minLength={8}
autoComplete="new-password"
className="input font-mono"
/>
</label>
<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 className="btn-primary text-xs" disabled={busy || pw.length < 8 || pw !== pw2}>
{busy ? 'Updating…' : 'Update password'}
</button>
</div>
</form>
);
}
@@ -7,7 +7,7 @@ import { Empty } from '../components/Empty';
import { absoluteTime, clip, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons';
export function PlayerLogs() {
export function PlayerLogs({ embedded }: { embedded?: boolean } = {}) {
const { data: players } = useApi<Player[]>('/players?take=500');
const [pid, setPid] = useState<number | null>(null);
const [filter, setFilter] = useState('');
@@ -49,10 +49,12 @@ export function PlayerLogs() {
return (
<div>
<PageHeader
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."
/>
{!embedded && (
<PageHeader
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."
/>
)}
<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">
+24 -13
View File
@@ -10,8 +10,10 @@ import { useToast } from '../components/Toast';
import { Empty } from '../components/Empty';
import { PlayerAvatar } from '../components/PlayerAvatar';
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 [query, setQuery] = useState(params.get('query') ?? '');
const [rows, setRows] = useState<Player[] | null>(null);
@@ -45,18 +47,24 @@ export function Players() {
if (next !== query) setQuery(next);
}, [params, query]);
const refreshBtn = (
<button onClick={() => search(query)} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</button>
);
return (
<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}>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</button>
}
/>
{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="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 }) {
const [data, setData] = useState<PlayerDetail | 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 = () => {
setErr(null);
@@ -196,7 +204,7 @@ function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => v
</div>
<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
key={t}
onClick={() => setTab(t)}
@@ -210,6 +218,7 @@ function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => v
{tab === 'overview' && <OverviewTab data={data} />}
{tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />}
{tab === 'grants' && <GrantsTab data={data} onChanged={reload} />}
{tab === 'gift' && <GiftPanel playerId={data.id} />}
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} />}
</div>
)}
@@ -576,6 +585,8 @@ function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () =>
className="btn-danger text-xs"
>Reset to starter</button>
</div>
<PasswordResetCard playerId={data.id} username={data.username} />
</div>
);
}
+16 -10
View File
@@ -9,21 +9,27 @@ import { useToast } from '../components/Toast';
import { absoluteTime, relativeTime } from '../lib/format';
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 [resolving, setResolving] = useState<Report | null>(null);
const refreshBtn = (
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
);
return (
<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}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
}
/>
{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>}
+4 -1
View File
@@ -8,7 +8,7 @@ import { Empty } from '../components/Empty';
import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast';
import { Confirm } from '../components/Confirm';
import { RefreshCw, Trash } from '../components/Icons';
import { RefreshCw, Trash, Upload } from '../components/Icons';
export function Rooms() {
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
@@ -55,6 +55,9 @@ export function 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."
actions={<>
<Link to="/import-room" className="btn-primary text-xs">
<Upload /> Import room
</Link>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
+17 -11
View File
@@ -54,7 +54,7 @@ interface RewardOptions {
equipment: RewardOption[];
}
export function Settings() {
export function Settings({ embedded }: { embedded?: boolean } = {}) {
const [settings, setSettings] = useState<ServerSettings | null>(null);
const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null);
const [rewardOptions, setRewardOptions] = useState<RewardOptions>({
@@ -252,18 +252,24 @@ export function Settings() {
}
};
const refreshBtn = (
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh
</button>
);
return (
<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}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh
</button>
}
/>
{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 && (
<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',
};
export function SignupCodes() {
export function SignupCodes({ embedded }: { embedded?: boolean } = {}) {
const [codes, setCodes] = useState<SignupCode[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [descriptor, setDescriptor] = useState('');
@@ -84,18 +84,24 @@ export function SignupCodes() {
}
};
const refreshBtn = (
<button onClick={load} className="btn-secondary text-xs">
<RefreshCw />
Refresh
</button>
);
return (
<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">
<RefreshCw />
Refresh
</button>
}
/>
{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 && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>