mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -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: '/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: '/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: '/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>
|
||||
);
|
||||
}
|
||||
@@ -6,16 +6,12 @@ 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}`);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Audit log"
|
||||
blurb="Every admin action with actor, target, and reason. Newest first."
|
||||
actions={<>
|
||||
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>
|
||||
@@ -24,8 +20,20 @@ export function Audit() {
|
||||
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</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>}
|
||||
|
||||
|
||||
@@ -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>
|
||||
{!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>
|
||||
{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={<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
{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={<button onClick={addNew} className="btn-primary text-xs"><Plus /> Add tip</button>}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,20 +45,14 @@ export function Passwords() {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="card !p-4 md:col-span-2 space-y-3">
|
||||
<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-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>
|
||||
<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>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">New password</span>
|
||||
@@ -71,7 +62,6 @@ export function Passwords() {
|
||||
value={pw}
|
||||
onChange={e => setPw(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="input flex-1 font-mono"
|
||||
/>
|
||||
@@ -87,23 +77,17 @@ export function Passwords() {
|
||||
value={pw2}
|
||||
onChange={e => setPw2(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="input 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.
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
<button className="btn-primary text-xs" disabled={busy || pw.length < 8 || pw !== pw2}>
|
||||
{busy ? 'Updating…' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{!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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Players"
|
||||
blurb="Search and moderate every account in the database."
|
||||
actions={
|
||||
const refreshBtn = (
|
||||
<button onClick={() => search(query)} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</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="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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Open reports"
|
||||
blurb="Oldest first. Resolving a report only closes the ticket — issue the actual moderation action separately."
|
||||
actions={
|
||||
const refreshBtn = (
|
||||
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</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>}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Server settings"
|
||||
blurb="Runtime toggles applied across every replica without a redeploy."
|
||||
actions={
|
||||
const refreshBtn = (
|
||||
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
|
||||
<RefreshCw className={busy ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</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 && (
|
||||
<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() {
|
||||
}
|
||||
};
|
||||
|
||||
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={
|
||||
const refreshBtn = (
|
||||
<button onClick={load} className="btn-secondary text-xs">
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</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 && (
|
||||
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
|
||||
|
||||
@@ -141,9 +141,9 @@ so both providers pick them up on existing DBs without a new migration
|
||||
|
||||
### 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
|
||||
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 —
|
||||
which creates their account bound to the device their game client
|
||||
reported, so the next launch logs straight in.
|
||||
|
||||
Reference in New Issue
Block a user