[www] tweaks to mod panel

This commit is contained in:
Devin Zuczek
2026-09-15 13:47:05 -04:00
parent 27ebc95109
commit 872bdfa64e
6 changed files with 513 additions and 164 deletions
+14 -3
View File
@@ -431,14 +431,25 @@ export async function getTopReported(
} }
/** /**
* Every ban in force right now, longest-lasting first — the staff panel's standing-bans * Every ban in force right now, MOST RECENTLY HANDED DOWN FIRST — the staff panel's
* list (`www`: `GET /api/staff/bans`). * standing-bans list (`www`: `GET /api/staff/bans`).
* *
* "In force" is `getActiveBan`'s test applied to the whole table rather than to one * "In force" is `getActiveBan`'s test applied to the whole table rather than to one
* player: a row whose `ban_expires` has passed has served its time and is not listed, * player: a row whose `ban_expires` has passed has served its time and is not listed,
* though it stays as the record that it happened. A player with several in force appears * though it stays as the record that it happened. A player with several in force appears
* once per ban — each is a separate report, and which report justified which ban is the * once per ban — each is a separate report, and which report justified which ban is the
* point of the list. * point of the list.
*
* Ordered by WHEN THE BAN LANDED because the list's job is catching mistakes: the ban most
* likely to be wrong is the one just handed down, and a moderator who has slipped goes
* looking for it at the top. Ordering by severity instead (longest-lasting first, as this
* did) buries a fresh one-day ban under every permanent ban ever issued — exactly the
* wrong way round for the one read this list exists to serve.
*
* `COALESCE(banned_at, created_at)`: a ban set before 0020_report_ban_audit.sql added
* `banned_at` has only its report's date to sort by, which is the same fallback the block
* screen uses. Without the coalesce those rows would sort as NULL — all together at one
* end, in no order at all.
*/ */
export async function getBansInForce( export async function getBansInForce(
db: D1Database, db: D1Database,
@@ -449,7 +460,7 @@ export async function getBansInForce(
.prepare( .prepare(
`SELECT * FROM report `SELECT * FROM report
WHERE banned = 1 AND (ban_expires IS NULL OR ban_expires > ?1) WHERE banned = 1 AND (ban_expires IS NULL OR ban_expires > ?1)
ORDER BY ban_expires IS NOT NULL, ban_expires DESC, id DESC ORDER BY COALESCE(banned_at, created_at) DESC, id DESC
LIMIT ?2` LIMIT ?2`
) )
.bind(now.toISOString(), Math.min(Math.max(take, 1), 500)) .bind(now.toISOString(), Math.min(Math.max(take, 1), 500))
+52
View File
@@ -4834,6 +4834,58 @@ describe('player reports', () => {
expect(banned.find((r) => r.playerId === 3350)?.bannedNow).toBe(true) expect(banned.find((r) => r.playerId === 3350)?.bannedNow).toBe(true)
}) })
// The standing-bans list exists to catch MISTAKES, so it is ordered by when the ban
// landed: the one most likely to be wrong is the one just handed down. Ordering by
// severity instead would bury a fresh one-day ban under every permanent ban ever
// issued.
test('getBansInForce puts the most recently handed-down ban first', async () => {
const first = await createReport(env.DB, { reporterPlayerId: 3371, reportedPlayerId: 3370 })
const second = await createReport(env.DB, { reporterPlayerId: 3373, reportedPlayerId: 3372 })
const third = await createReport(env.DB, { reporterPlayerId: 3375, reportedPlayerId: 3374 })
// Handed down oldest-first, and deliberately mixed in severity: the permanent one
// is banned FIRST, so an order by "longest lasting" would float it to the top.
await banFromReport(env.DB, first.id)
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(first.id, '2024-01-01T00:00:00.000Z')
.run()
await banFromReport(env.DB, second.id, { banExpires: '2999-01-01T00:00:00.000Z' })
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(second.id, '2024-06-01T00:00:00.000Z')
.run()
await banFromReport(env.DB, third.id, { banExpires: '2999-01-01T00:00:00.000Z' })
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(third.id, '2025-01-01T00:00:00.000Z')
.run()
const ordered = (await getBansInForce(env.DB))
.map((b) => b.id)
.filter((id) => [first.id, second.id, third.id].includes(id))
expect(ordered).toEqual([third.id, second.id, first.id])
})
// A ban set before 0020 added `banned_at` has only its report's date to sort by —
// the same fallback the block screen uses. Without the coalesce every such row would
// sort as NULL: all at one end, in no order at all.
test('getBansInForce falls back to created_at for a ban with no banned_at', async () => {
const old = await createReport(env.DB, { reporterPlayerId: 3381, reportedPlayerId: 3380 })
const recent = await createReport(env.DB, { reporterPlayerId: 3383, reportedPlayerId: 3382 })
// The pre-migration shape: banned, with no record of when.
await env.DB.prepare(
'UPDATE report SET banned = 1, banned_at = NULL, created_at = ?2 WHERE id = ?1'
)
.bind(old.id, '2023-01-01T00:00:00.000Z')
.run()
await banFromReport(env.DB, recent.id)
const ordered = (await getBansInForce(env.DB))
.map((b) => b.id)
.filter((id) => [old.id, recent.id].includes(id))
// The undated one sorts by its report's date, so it lands behind today's ban
// rather than at an arbitrary end of the list.
expect(ordered).toEqual([recent.id, old.id])
})
test('getBansInForce lists in-force bans only, and getReportById reads one row', async () => { test('getBansInForce lists in-force bans only, and getReportById reads one row', async () => {
const permanent = await createReport(env.DB, { const permanent = await createReport(env.DB, {
reporterPlayerId: 3361, reporterPlayerId: 3361,
+41 -14
View File
@@ -441,20 +441,40 @@ const coachMessage = (
/** Minimal history-based router: current pathname + a navigate() that pushes state. */ /** Minimal history-based router: current pathname + a navigate() that pushes state. */
function useRouter() { function useRouter() {
const [path, setPath] = useState(() => window.location.pathname) // Pathname and query string are tracked SEPARATELY rather than as one string, because
// the pages match on the pathname alone and one of them is reached with a query on it:
// `/claim?code=…` is Discord's redirect, so a `path` that carried the search would stop
// matching `/claim` the moment it mattered. `search` is here for the pages whose own
// state belongs in the URL — the moderation panel's filters and paging — so that the
// back button walks back through them instead of leaving the page.
const [location, setLocation] = useState(() => ({
path: window.location.pathname,
search: window.location.search,
}))
useEffect(() => { useEffect(() => {
const onPop = () => setPath(window.location.pathname) const onPop = () =>
setLocation({ path: window.location.pathname, search: window.location.search })
window.addEventListener('popstate', onPop) window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop) return () => window.removeEventListener('popstate', onPop)
}, []) }, [])
const navigate = useCallback((to: string) => { const navigate = useCallback((to: string) => {
if (to !== window.location.pathname) { // `to` may carry a query string, so the "did anything change" test is against the
// whole of what's on screen — otherwise a filter change (same path, new query) would
// never push, and the back button would have nothing to walk back through.
const [path, search = ''] = to.split('?')
// Read before pushing: afterwards `window.location` IS the destination, and every
// "did the page change" test would answer no.
const samePage = path === window.location.pathname
if (to !== window.location.pathname + window.location.search) {
window.history.pushState(null, '', to) window.history.pushState(null, '', to)
window.scrollTo(0, 0) // Staying on the same page with a different query is a re-filtered list, where
// jumping to the top would throw away the reader's place. A real page change
// still scrolls up.
if (!samePage) window.scrollTo(0, 0)
} }
setPath(to) setLocation({ path: path ?? '/', search: search === '' ? '' : `?${search}` })
}, []) }, [])
return { path, navigate } return { ...location, navigate }
} }
type Navigate = (to: string) => void type Navigate = (to: string) => void
@@ -709,7 +729,7 @@ export function App() {
// undefined until the config lands. Signup is treated as closed until told otherwise, // undefined until the config lands. Signup is treated as closed until told otherwise,
// so a slow (or failed) config fetch can't flash a form the server would refuse. // so a slow (or failed) config fetch can't flash a form the server would refuse.
const [config, setConfig] = useState<SiteConfig | undefined>(undefined) const [config, setConfig] = useState<SiteConfig | undefined>(undefined)
const { path, navigate } = useRouter() const { path, search, navigate } = useRouter()
const roomId = roomIdFromPath(path) const roomId = roomIdFromPath(path)
useEffect(() => { useEffect(() => {
@@ -768,12 +788,17 @@ export function App() {
// Its own page rather than a dashboard tab: this path is Discord's registered // Its own page rather than a dashboard tab: this path is Discord's registered
// redirect URI, so it has to be one stable URL a cold load can land on. // redirect URI, so it has to be one stable URL a cold load can land on.
<ClaimPage account={account} config={config} navigate={navigate} /> <ClaimPage account={account} config={config} navigate={navigate} />
) : path === '/moderation' ? ( ) : path === '/moderation' || path.startsWith('/moderation/') ? (
// A whole surface rather than another dashboard tab, and its own URL so a // A whole surface rather than another dashboard tab, and its own URLS: the
// moderator can keep it open. The path is deliberately NOT in wrangler.jsonc's // panel routes UNDER this prefix (`/moderation/players/123`, and its filters in
// `run_worker_first`, so a cold load falls through to the SPA shell; the page // the query string), so the back button walks back through a moderator's
// then gates itself on the token's role, and every endpoint behind it re-checks. // session instead of dropping them on the homepage. Matched by prefix here for
<ModerationPage account={account} navigate={navigate} /> // that reason; the panel picks the view apart itself.
//
// None of these paths is in wrangler.jsonc's `run_worker_first`, so a cold load
// on any of them falls through to the SPA shell; the page then gates itself on
// the token's role, and every endpoint behind it re-checks.
<ModerationPage account={account} path={path} search={search} navigate={navigate} />
) : roomId !== null ? ( ) : roomId !== null ? (
<RoomPage account={account} roomId={roomId} navigate={navigate} /> <RoomPage account={account} roomId={roomId} navigate={navigate} />
) : ( ) : (
@@ -839,7 +864,9 @@ function NavBar({
<Link <Link
to="/moderation" to="/moderation"
navigate={navigate} navigate={navigate}
className={path === '/moderation' ? 'active' : ''} className={
path === '/moderation' || path.startsWith('/moderation/') ? 'active' : ''
}
> >
Moderation Moderation
</Link> </Link>
+336 -136
View File
@@ -35,6 +35,43 @@ const TABS: Array<{ id: Tab; label: string }> = [
{ id: 'new', label: 'File a report' }, { id: 'new', label: 'File a report' },
] ]
const TAB_IDS = new Set<string>(TABS.map((t) => t.id))
/**
* Which view a `/moderation/…` path names.
*
* Every one of these is a REAL URL rather than a piece of component state, so the back
* button walks back out of a drill-down instead of leaving the panel — which is what it
* did when the tab and the open player were held in `useState`: a moderator two clicks
* deep pressed Back and landed on the homepage, losing the whole session. Along the way it
* makes each view linkable, which is how one moderator hands another a player to look at.
*
* `/moderation` alone is the queue, so the nav link needs no tab in it and a bookmark of
* the bare path still opens on something.
*/
type View =
| { kind: 'tab'; tab: Tab }
| { kind: 'player'; playerId: number }
| { kind: 'ban'; reportId: number }
function viewForPath(path: string): View {
const rest = path.replace(/^\/moderation\/?/, '').replace(/\/$/, '')
if (rest === '') return { kind: 'tab', tab: 'queue' }
const player = /^players\/(\d+)$/.exec(rest)
if (player) return { kind: 'player', playerId: Number.parseInt(player[1]!, 10) }
const ban = /^reports\/(\d+)\/ban$/.exec(rest)
if (ban) return { kind: 'ban', reportId: Number.parseInt(ban[1]!, 10) }
// An unknown segment falls back to the queue rather than rendering nothing: these paths
// are typed and pasted, and a typo should land somewhere usable.
return { kind: 'tab', tab: TAB_IDS.has(rest) ? (rest as Tab) : 'queue' }
}
/** Where a tab lives. The queue is the bare path, not `/moderation/queue`. */
const tabPath = (tab: Tab): string => (tab === 'queue' ? '/moderation' : `/moderation/${tab}`)
/** /**
* A stored report, as `www` serves the row — snake_case, because it IS the row. Not every * A stored report, as `www` serves the row — snake_case, because it IS the row. Not every
* column is rendered; the ones that are not (the measured heights, the instance type) are * column is rendered; the ones that are not (the measured heights, the instance type) are
@@ -240,22 +277,31 @@ function useNames(ids: number[]) {
export function ModerationPage({ export function ModerationPage({
account, account,
path,
search,
navigate, navigate,
}: { }: {
account: { accountId: number; username: string } | null | undefined account: { accountId: number; username: string } | null | undefined
/** The full pathname — the panel routes under `/moderation`; see {@link viewForPath}. */
path: string
/** The query string, where the search tab keeps its filters and its page. */
search: string
navigate: (to: string) => void navigate: (to: string) => void
}) { }) {
const [tab, setTab] = useState<Tab>('queue') const view = viewForPath(path)
// The player whose history is open, or null for the tab's own view. Local state rather // Bumped after a write that leaves you where you are — lifting a ban from a table. A
// than a route: it's a drill-down inside the panel, and a moderator walks in and out of // write that NAVIGATES (banning, filing a report) needs no bump: the view it lands on
// several in a sitting without wanting each one in their back button. // mounts fresh and reads for itself.
const [openPlayer, setOpenPlayer] = useState<number | null>(null)
// The report a ban is being composed against. Held here so the dialog can be opened
// from any of the tables.
const [banning, setBanning] = useState<ReportRow | null>(null)
// Bumped after any write, to re-read whichever tables are on screen.
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0)
const changed = useCallback(() => setRevision((n) => n + 1), []) const changed = useCallback(() => setRevision((n) => n + 1), [])
const openPlayer = useCallback(
(playerId: number) => navigate(`/moderation/players/${playerId}`),
[navigate]
)
const openBan = useCallback(
(report: ReportRow) => navigate(`/moderation/reports/${report.id}/ban`),
[navigate]
)
if (account === undefined) { if (account === undefined) {
return ( return (
@@ -306,64 +352,83 @@ export function ModerationPage({
</div> </div>
</section> </section>
{openPlayer !== null ? ( {view.kind === 'player' ? (
<PlayerHistory <PlayerHistory
playerId={openPlayer} playerId={view.playerId}
revision={revision} revision={revision}
onBan={setBanning} navigate={navigate}
onClose={() => setOpenPlayer(null)} onBan={openBan}
/> />
) : view.kind === 'ban' ? (
<BanDialog reportId={view.reportId} navigate={navigate} />
) : ( ) : (
<div className="workspace"> <div className="workspace">
<nav className="vtabs"> <nav className="vtabs">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
key={t.id} key={t.id}
className={t.id === tab ? 'active' : ''} className={t.id === view.tab ? 'active' : ''}
onClick={() => setTab(t.id)} onClick={() => navigate(tabPath(t.id))}
> >
{t.label} {t.label}
</button> </button>
))} ))}
</nav> </nav>
<div className="panel"> <div className="panel">
{tab === 'queue' ? ( {view.tab === 'queue' ? (
<ReportQueue revision={revision} onOpenPlayer={setOpenPlayer} /> <ReportQueue
) : tab === 'search' ? ( search={search}
<ReportSearch
revision={revision} revision={revision}
onOpenPlayer={setOpenPlayer} navigate={navigate}
onBan={setBanning} onOpenPlayer={openPlayer}
/>
) : view.tab === 'search' ? (
<ReportSearch
search={search}
revision={revision}
navigate={navigate}
onOpenPlayer={openPlayer}
onBan={openBan}
onChanged={changed} onChanged={changed}
/> />
) : tab === 'bans' ? ( ) : view.tab === 'bans' ? (
<StandingBans revision={revision} onOpenPlayer={setOpenPlayer} onChanged={changed} /> <StandingBans revision={revision} onOpenPlayer={openPlayer} onChanged={changed} />
) : ( ) : (
<FileReport // Filing a report navigates straight to the ban dialog for it — filing one
onFiled={(report) => { // is almost always the first half of banning somebody, and the new report's
changed() // id is then in the URL, so the ban is a real page rather than a modal that
setBanning(report) // a reload would lose.
}} <FileReport onFiled={openBan} />
/>
)} )}
</div> </div>
</div> </div>
)} )}
{banning !== null && (
<BanDialog
report={banning}
onClose={() => setBanning(null)}
onDone={() => {
setBanning(null)
changed()
}}
/>
)}
</main> </main>
) )
} }
/**
* Back out of a drill-down — the in-page counterpart of the browser's own back button,
* and deliberately the SAME action, so the two can't disagree about where "back" is.
*
* Falls forward to the panel's front page when there is no history to pop: a moderator who
* arrived on a pasted link has nothing behind them, and a button that did nothing would
* read as broken.
*/
function BackLink({ navigate, label }: { navigate: (to: string) => void; label: string }) {
return (
<button
className="linkish"
onClick={() => {
if (window.history.length > 1) window.history.back()
else navigate('/moderation')
}}
>
← {label}
</button>
)
}
/** /**
* The report queue: who has collected the most reports lately. * The report queue: who has collected the most reports lately.
* *
@@ -373,14 +438,27 @@ export function ModerationPage({
* is a problem now rather than whoever has ever accumulated the most. * is a problem now rather than whoever has ever accumulated the most.
*/ */
function ReportQueue({ function ReportQueue({
search,
revision, revision,
navigate,
onOpenPlayer, onOpenPlayer,
}: { }: {
search: string
revision: number revision: number
navigate: (to: string) => void
onOpenPlayer: (id: number) => void onOpenPlayer: (id: number) => void
}) { }) {
const [sinceDays, setSinceDays] = useState('30') // In the URL for the same reason the search's filters are: a moderator who widens the
const [minReports, setMinReports] = useState('3') // window to 90 days, opens a player and presses Back must come back to the 90 days
// they were reading, not to a list quietly reset to the default.
const params = new URLSearchParams(search)
const sinceDays = params.get('sinceDays') ?? '30'
const minReports = params.get('minReports') ?? '3'
const queueUrl = (next: { sinceDays?: string; minReports?: string }) => {
const updated = new URLSearchParams({ sinceDays, minReports, ...next })
return `/moderation?${updated.toString()}`
}
const { data, error } = useStaffData<ReportedPlayerTally[]>( const { data, error } = useStaffData<ReportedPlayerTally[]>(
`/api/staff/reports/top-reported?sinceDays=${encodeURIComponent(sinceDays)}&minReports=${encodeURIComponent(minReports)}`, `/api/staff/reports/top-reported?sinceDays=${encodeURIComponent(sinceDays)}&minReports=${encodeURIComponent(minReports)}`,
[revision] [revision]
@@ -398,7 +476,10 @@ function ReportQueue({
<div className="mod-filters"> <div className="mod-filters">
<label> <label>
Window Window
<select value={sinceDays} onChange={(e) => setSinceDays(e.target.value)}> <select
value={sinceDays}
onChange={(e) => navigate(queueUrl({ sinceDays: e.target.value }))}
>
<option value="7">Last 7 days</option> <option value="7">Last 7 days</option>
<option value="30">Last 30 days</option> <option value="30">Last 30 days</option>
<option value="90">Last 90 days</option> <option value="90">Last 90 days</option>
@@ -408,12 +489,20 @@ function ReportQueue({
</label> </label>
<label> <label>
Minimum reports Minimum reports
<input {/* A select rather than a number box: each change is now a navigation, and a
type="number" free-text field would push a history entry per digit typed — which would
min="1" make the back button useless again, the thing this page is fixing. A
threshold is coarse anyway; nobody needs to ask for seven. */}
<select
value={minReports} value={minReports}
onChange={(e) => setMinReports(e.target.value)} onChange={(e) => navigate(queueUrl({ minReports: e.target.value }))}
/> >
<option value="1">1 or more</option>
<option value="2">2 or more</option>
<option value="3">3 or more</option>
<option value="5">5 or more</option>
<option value="10">10 or more</option>
</select>
</label> </label>
</div> </div>
@@ -459,81 +548,145 @@ function ReportQueue({
) )
} }
/** The search form's state — mirrors the query params `searchReportsHandler` reads. */ /**
* The search form's state. These are the SAME names the staff endpoint reads as query
* params (`searchReportsHandler`) and the same ones the panel's own URL carries, so the
* form, the address bar and the request can't drift apart — one spelling throughout.
*
* `from`/`to` are the `YYYY-MM-DD` a date input gives, converted to instants only when the
* request goes out; keeping the URL in the input's own format is what lets a bookmarked
* search repopulate the boxes exactly as they were typed.
*/
interface SearchFilters { interface SearchFilters {
reported: string reportedPlayerId: string
reporter: string reporterPlayerId: string
category: string reportCategory: string
banned: string banned: string
from: string from: string
to: string to: string
} }
const EMPTY_FILTERS: SearchFilters = { const EMPTY_FILTERS: SearchFilters = {
reported: '', reportedPlayerId: '',
reporter: '', reporterPlayerId: '',
category: '', reportCategory: '',
banned: '', banned: '',
from: '', from: '',
to: '', to: '',
} }
/** Read the filters back out of the panel's URL. Absent keys read as empty. */
function filtersFromSearch(search: string): SearchFilters {
const params = new URLSearchParams(search)
const read = (key: keyof SearchFilters) => params.get(key) ?? ''
return {
reportedPlayerId: read('reportedPlayerId'),
reporterPlayerId: read('reporterPlayerId'),
reportCategory: read('reportCategory'),
banned: read('banned'),
from: read('from'),
to: read('to'),
}
}
/**
* The panel URL for a set of filters and a page. Empty values are left OUT rather than
* written as blanks, so an unfiltered search is the bare `/moderation/search` and the
* address bar says only what was actually asked for.
*/
function searchUrl(filters: SearchFilters, skip: number): string {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(filters)) {
if (value !== '') params.set(key, value)
}
if (skip > 0) params.set('skip', String(skip))
const query = params.toString()
return query === '' ? '/moderation/search' : `/moderation/search?${query}`
}
/** /**
* Search the report log. * Search the report log.
* *
* The filters and the page live in the URL, not in component state: a moderator searches,
* opens a player, and presses Back — and has to land on the results they were reading,
* not on an empty form. It also makes a search linkable, which is how one moderator hands
* another a slice of the log to look at.
*
* The two player boxes take either an id or an `@username`, resolved through `accounts`' * The two player boxes take either an id or an `@username`, resolved through `accounts`'
* search before the query goes out — a moderator following up a Discord report has a name, * search before the query goes out — a moderator following up a Discord report has a name,
* not a number, and making them look it up elsewhere first is the kind of friction that * not a number, and making them look it up elsewhere first is the kind of friction that
* ends in nobody using the panel. * ends in nobody using the panel. The URL holds the resolved ID, since that is what was
* searched and what makes the link reproducible.
*/ */
function ReportSearch({ function ReportSearch({
search,
revision, revision,
navigate,
onOpenPlayer, onOpenPlayer,
onBan, onBan,
onChanged, onChanged,
}: { }: {
search: string
revision: number revision: number
navigate: (to: string) => void
onOpenPlayer: (id: number) => void onOpenPlayer: (id: number) => void
onBan: (report: ReportRow) => void onBan: (report: ReportRow) => void
onChanged: () => void onChanged: () => void
}) { }) {
const [form, setForm] = useState<SearchFilters>(EMPTY_FILTERS) const applied = filtersFromSearch(search)
// The query actually in flight, separate from what's typed: a search runs when it is const skip = Number.parseInt(new URLSearchParams(search).get('skip') ?? '0', 10) || 0
// submitted, not on every keystroke, and the ids in it are resolved by then. // What is TYPED, which runs ahead of what is applied — a search happens on submit, not
const [query, setQuery] = useState<string | null>('?take=' + PAGE_SIZE) // on every keystroke. Keyed on the URL so that arriving at a different search (a link,
const [skip, setSkip] = useState(0) // or the back button) resets the boxes to it instead of stranding the last thing typed.
const [form, setForm] = useState<SearchFilters>(applied)
const [formKey, setFormKey] = useState(search)
if (formKey !== search) {
setFormKey(search)
setForm(applied)
}
const { pending, error: formError, run } = useAction() const { pending, error: formError, run } = useAction()
const path = query === null ? null : `/api/staff/reports${query}&skip=${skip}` const params = new URLSearchParams({ take: String(PAGE_SIZE), skip: String(skip) })
const { data, error } = useStaffData<{ reports: ReportRow[]; total: number }>(path, [revision]) if (applied.reportedPlayerId !== '') params.set('reportedPlayerId', applied.reportedPlayerId)
if (applied.reporterPlayerId !== '') params.set('reporterPlayerId', applied.reporterPlayerId)
if (applied.reportCategory !== '') params.set('reportCategory', applied.reportCategory)
if (applied.banned !== '') params.set('banned', applied.banned)
// A date input gives a bare `YYYY-MM-DD`, so the window is sent as local midnights,
// which is what the moderator meant by it. `to` is EXCLUSIVE on the server, so the day
// typed there is included only by pushing the bound to the next midnight.
if (applied.from !== '') params.set('from', new Date(applied.from).toISOString())
if (applied.to !== '') {
const to = new Date(applied.to)
to.setDate(to.getDate() + 1)
params.set('to', to.toISOString())
}
const { data, error } = useStaffData<{ reports: ReportRow[]; total: number }>(
`/api/staff/reports?${params.toString()}`,
[revision]
)
const reports = data?.reports ?? [] const reports = data?.reports ?? []
const names = useNames(reports.flatMap((r) => [r.reported_player_id, r.reporter_player_id])) const names = useNames(reports.flatMap((r) => [r.reported_player_id, r.reporter_player_id]))
const submit = (e: React.FormEvent) => { const submit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
void run(async () => { void run(async () => {
const params = new URLSearchParams({ take: String(PAGE_SIZE) }) // Either box may hold a name; resolve before navigating so the URL carries the id,
// Either box may hold a name; resolve before searching so the query is by id, as // as the report rows do.
// the report rows are. const resolved: SearchFilters = {
if (form.reported.trim() !== '') { ...form,
params.set('reportedPlayerId', String(await playerIdFrom(form.reported))) reportedPlayerId:
form.reportedPlayerId.trim() === ''
? ''
: String(await playerIdFrom(form.reportedPlayerId)),
reporterPlayerId:
form.reporterPlayerId.trim() === ''
? ''
: String(await playerIdFrom(form.reporterPlayerId)),
} }
if (form.reporter.trim() !== '') { // Back to page one: the new filters have their own result set, and staying on
params.set('reporterPlayerId', String(await playerIdFrom(form.reporter))) // page three of it would usually show nothing.
} navigate(searchUrl(resolved, 0))
if (form.category !== '') params.set('reportCategory', form.category)
if (form.banned !== '') params.set('banned', form.banned)
// A date input gives a bare `YYYY-MM-DD`; `to` is exclusive on the server, so a
// day entered there includes that whole day only if it is pushed to the next
// midnight. Both are sent as local midnights, which is what the moderator meant.
if (form.from !== '') params.set('from', new Date(form.from).toISOString())
if (form.to !== '') {
const to = new Date(form.to)
to.setDate(to.getDate() + 1)
params.set('to', to.toISOString())
}
setSkip(0)
setQuery(`?${params.toString()}`)
return '' return ''
}) })
} }
@@ -550,24 +703,24 @@ function ReportSearch({
<label> <label>
Reported player Reported player
<input <input
value={form.reported} value={form.reportedPlayerId}
placeholder="@name or id" placeholder="@name or id"
onChange={(e) => setForm({ ...form, reported: e.target.value })} onChange={(e) => setForm({ ...form, reportedPlayerId: e.target.value })}
/> />
</label> </label>
<label> <label>
Reported by Reported by
<input <input
value={form.reporter} value={form.reporterPlayerId}
placeholder="@name or id" placeholder="@name or id"
onChange={(e) => setForm({ ...form, reporter: e.target.value })} onChange={(e) => setForm({ ...form, reporterPlayerId: e.target.value })}
/> />
</label> </label>
<label> <label>
Category Category
<select <select
value={form.category} value={form.reportCategory}
onChange={(e) => setForm({ ...form, category: e.target.value })} onChange={(e) => setForm({ ...form, reportCategory: e.target.value })}
> >
<option value="">Any</option> <option value="">Any</option>
{Object.entries(CATEGORY_LABEL).map(([id, label]) => ( {Object.entries(CATEGORY_LABEL).map(([id, label]) => (
@@ -608,14 +761,12 @@ function ReportSearch({
<button type="submit" disabled={pending}> <button type="submit" disabled={pending}>
{pending ? 'Searching…' : 'Search'} {pending ? 'Searching…' : 'Search'}
</button> </button>
{/* Clearing is a navigation like any other search, so it goes in the history
too — Back after a mis-click returns to what was being read. */}
<button <button
type="button" type="button"
className="linkish" className="linkish"
onClick={() => { onClick={() => navigate(searchUrl(EMPTY_FILTERS, 0))}
setForm(EMPTY_FILTERS)
setSkip(0)
setQuery(`?take=${PAGE_SIZE}`)
}}
> >
Clear Clear
</button> </button>
@@ -638,14 +789,22 @@ function ReportSearch({
onBan={onBan} onBan={onBan}
onChanged={onChanged} onChanged={onChanged}
/> />
{/* Paging is navigation, so each page is its own history entry: Back walks
back through the pages a moderator read rather than out of the panel. */}
<div className="mod-pager"> <div className="mod-pager">
<button disabled={skip === 0} onClick={() => setSkip(Math.max(skip - PAGE_SIZE, 0))}> <button
disabled={skip === 0}
onClick={() => navigate(searchUrl(applied, Math.max(skip - PAGE_SIZE, 0)))}
>
Previous Previous
</button> </button>
<span className="muted"> <span className="muted">
{skip + 1}–{Math.min(skip + PAGE_SIZE, total)} of {total} {skip + 1}–{Math.min(skip + PAGE_SIZE, total)} of {total}
</span> </span>
<button disabled={skip + PAGE_SIZE >= total} onClick={() => setSkip(skip + PAGE_SIZE)}> <button
disabled={skip + PAGE_SIZE >= total}
onClick={() => navigate(searchUrl(applied, skip + PAGE_SIZE))}
>
Next Next
</button> </button>
</div> </div>
@@ -791,8 +950,9 @@ function StandingBans({
<section className="card"> <section className="card">
<h2>Standing bans</h2> <h2>Standing bans</h2>
<p className="muted"> <p className="muted">
Bans in force right now, longest-lasting first. An expired ban isn&apos;t here — it has Bans in force right now, most recent first — so a ban handed down by mistake is at the top,
served its time, and the report stays as the record that it happened. where you would go looking for it. An expired ban isn&apos;t here: it has served its time,
and the report stays as the record that it happened.
</p> </p>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{data === null ? ( {data === null ? (
@@ -868,20 +1028,22 @@ function StandingBans({
function PlayerHistory({ function PlayerHistory({
playerId, playerId,
revision, revision,
navigate,
onBan, onBan,
onClose,
}: { }: {
playerId: number playerId: number
revision: number revision: number
navigate: (to: string) => void
onBan: (report: ReportRow) => void onBan: (report: ReportRow) => void
onClose: () => void
}) { }) {
// Bumped by a write that leaves you on this page — lifting a ban from the table below.
const [ownRevision, setOwnRevision] = useState(0)
const { data, error } = useStaffData<{ const { data, error } = useStaffData<{
playerId: number playerId: number
reports: ReportRow[] reports: ReportRow[]
warnings: WarningRow[] warnings: WarningRow[]
activeBan: ReportRow | null activeBan: ReportRow | null
}>(`/api/staff/players/${playerId}`, [revision]) }>(`/api/staff/players/${playerId}`, [revision, ownRevision])
const reports = data?.reports ?? [] const reports = data?.reports ?? []
const warnings = data?.warnings ?? [] const warnings = data?.warnings ?? []
const names = useNames([ const names = useNames([
@@ -893,9 +1055,7 @@ function PlayerHistory({
return ( return (
<> <>
<section className="card"> <section className="card">
<button className="linkish" onClick={onClose}> <BackLink navigate={navigate} label="Back to the tables" />
← Back to the tables
</button>
<h2> <h2>
<PlayerName id={playerId} names={names} /> <PlayerName id={playerId} names={names} />
</h2> </h2>
@@ -926,7 +1086,12 @@ function PlayerHistory({
{reports.length === 0 ? ( {reports.length === 0 ? (
<p className="muted">Nobody has reported this player.</p> <p className="muted">Nobody has reported this player.</p>
) : ( ) : (
<ReportTable reports={reports} names={names} onBan={onBan} onChanged={onClose} /> <ReportTable
reports={reports}
names={names}
onBan={onBan}
onChanged={() => setOwnRevision((n) => n + 1)}
/>
)} )}
</section> </section>
@@ -1055,8 +1220,7 @@ function FileReport({ onFiled }: { onFiled: (report: ReportRow) => void }) {
const [player, setPlayer] = useState('') const [player, setPlayer] = useState('')
const [category, setCategory] = useState(String(KickReportCategory.Misc)) const [category, setCategory] = useState(String(KickReportCategory.Misc))
const [details, setDetails] = useState('') const [details, setDetails] = useState('')
const [roomId, setRoomId] = useState('') const { pending, error, run } = useAction()
const { pending, error, done, run } = useAction()
return ( return (
<section className="card"> <section className="card">
@@ -1075,14 +1239,13 @@ function FileReport({ onFiled }: { onFiled: (report: ReportRow) => void }) {
reportedPlayerId: await playerIdFrom(player), reportedPlayerId: await playerIdFrom(player),
reportCategory: Number(category), reportCategory: Number(category),
details: details.trim(), details: details.trim(),
roomId: roomId.trim() === '' ? 0 : Number(roomId),
}, },
}) })
setPlayer('') // Straight to the ban page for the new report, which names it — so no
setDetails('') // success line is returned here: this form is gone by the time one could
setRoomId('') // be read, and the fields don't need clearing for the same reason.
onFiled(report) onFiled(report)
return `Filed report #${report.id}.` return ''
}) })
}} }}
> >
@@ -1110,21 +1273,11 @@ function FileReport({ onFiled }: { onFiled: (report: ReportRow) => void }) {
<textarea <textarea
value={details} value={details}
rows={4} rows={4}
placeholder="What you saw, and where you saw it. Kept internally." placeholder="What you saw, where you saw it, and anything a second moderator would need. Kept internally."
onChange={(e) => setDetails(e.target.value)} onChange={(e) => setDetails(e.target.value)}
/> />
</label> </label>
<label>
Room id (optional)
<input
value={roomId}
inputMode="numeric"
placeholder="If it happened in a particular room"
onChange={(e) => setRoomId(e.target.value)}
/>
</label>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}> <button type="submit" disabled={pending}>
{pending ? 'Filing…' : 'File report'} {pending ? 'Filing…' : 'File report'}
</button> </button>
@@ -1153,21 +1306,58 @@ const BAN_LENGTHS: Array<{ label: string; days: number | null }> = [
* Says out loud what applying it does beyond the row: the player is thrown out of the * Says out loud what applying it does beyond the row: the player is thrown out of the
* instance they are standing in, which is the part that is not obvious from "ban". * instance they are standing in, which is the part that is not obvious from "ban".
*/ */
function BanDialog({ function BanDialog({ reportId, navigate }: { reportId: number; navigate: (to: string) => void }) {
report,
onClose,
onDone,
}: {
report: ReportRow
onClose: () => void
onDone: () => void
}) {
const [choice, setChoice] = useState('7') const [choice, setChoice] = useState('7')
const { pending, error, run } = useAction() const { pending, error, run } = useAction()
const names = useNames([report.reported_player_id]) // The report is READ here rather than handed down as a prop, which is what makes the
// URL self-sufficient: a reload, or a pasted `/moderation/reports/12/ban`, opens the
// same form instead of a blank page. It is also the report as it stands NOW — if
// somebody else banned it while this was being opened, that shows.
const { data: report, error: loadError } = useStaffData<ReportRow>(
`/api/staff/reports/${reportId}`
)
const names = useNames(report ? [report.reported_player_id] : [])
if (loadError !== '') {
return (
<section className="card">
<BackLink navigate={navigate} label="Back to the tables" />
<p className="error">{loadError}</p>
</section>
)
}
if (report === null) {
return (
<section className="card">
<p className="muted">Loading report #{reportId}…</p>
</section>
)
}
// Already actioned — reached by a stale link or the back button after banning. Shown
// as the standing ban it is rather than as a form that would silently re-ban and
// restart the clock.
if (report.banned === 1) {
return (
<section className="card">
<BackLink navigate={navigate} label="Back to the tables" />
<h2>
<PlayerName id={report.reported_player_id} names={names} /> is already banned
</h2>
<p className="muted">
Report #{report.id} — {expiryLabel(report)}. Lift it from the tables if this was a
mistake.
</p>
<button onClick={() => navigate(`/moderation/players/${report.reported_player_id}`)}>
See their history
</button>
</section>
)
}
return ( return (
<section className="card mod-dialog"> <section className="card mod-dialog">
<BackLink navigate={navigate} label="Back to the tables" />
<h2> <h2>
Ban <PlayerName id={report.reported_player_id} names={names} /> Ban <PlayerName id={report.reported_player_id} names={names} />
</h2> </h2>
@@ -1193,7 +1383,10 @@ function BanDialog({
? { banned: true, permanent: true } ? { banned: true, permanent: true }
: { banned: true, days: length.days }, : { banned: true, days: length.days },
}) })
onDone() // Onto the player's record rather than back where they came from: it is
// the confirmation that the ban landed and the place to lift it from if it
// was wrong, and it means Back doesn't return to a spent form.
navigate(`/moderation/players/${report.reported_player_id}`)
return '' return ''
}) })
}} }}
@@ -1218,7 +1411,14 @@ function BanDialog({
<button type="submit" disabled={pending}> <button type="submit" disabled={pending}>
{pending ? 'Banning…' : 'Apply ban'} {pending ? 'Banning…' : 'Apply ban'}
</button> </button>
<button type="button" className="linkish" onClick={onClose}> <button
type="button"
className="linkish"
onClick={() => {
if (window.history.length > 1) window.history.back()
else navigate('/moderation')
}}
>
Cancel Cancel
</button> </button>
</div> </div>
+7 -7
View File
@@ -252,10 +252,13 @@ export async function getReportHandler(c: Context<App>) {
* The reporter is the moderator, from the token, never the body: that IS the record of who * The reporter is the moderator, from the token, never the body: that IS the record of who
* raised it, and it is why nothing marks these rows as staff-created. * raised it, and it is why nothing marks these rows as staff-created.
* *
* Deliberately minimal. The heights the game measures, the instance type and the * WHO, WHAT KIND and WHY, and nothing else. Everything else on a report describes a
* event/invention/item ids all describe a client-side moment that did not happen here, so * client-side moment that did not happen here: the heights the game measured, the instance
* they are not accepted — a hand-written report carries only who, what kind, why, and * type, the event/invention/item ids — and `room_id`, which a player's report gets from
* optionally where. * the client that filed it. A moderator does not know a room's numeric id and would have
* to go and look it up, so the room goes in `details` along with the rest of the account
* of what happened. A `roomId` in the body is therefore not read; player reports still
* carry the column, and it is theirs alone.
*/ */
export async function createReportHandler(c: Context<App>) { export async function createReportHandler(c: Context<App>) {
const moderatorId = staffId(c) const moderatorId = staffId(c)
@@ -263,7 +266,6 @@ export async function createReportHandler(c: Context<App>) {
reportedPlayerId?: unknown reportedPlayerId?: unknown
reportCategory?: unknown reportCategory?: unknown
details?: unknown details?: unknown
roomId?: unknown
} | null } | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400) if (body === null) return c.json({ error: 'Invalid request body' }, 400)
@@ -277,13 +279,11 @@ export async function createReportHandler(c: Context<App>) {
return c.json({ error: 'You cannot file a report against yourself' }, 400) return c.json({ error: 'You cannot file a report against yourself' }, 400)
} }
const roomId = Number(body.roomId ?? 0)
const report = await createReport(c.env.DB, { const report = await createReport(c.env.DB, {
reporterPlayerId: moderatorId, reporterPlayerId: moderatorId,
reportedPlayerId, reportedPlayerId,
reportCategory: Number.isInteger(Number(body.reportCategory)) ? Number(body.reportCategory) : 0, reportCategory: Number.isInteger(Number(body.reportCategory)) ? Number(body.reportCategory) : 0,
details: typeof body.details === 'string' && body.details !== '' ? body.details : null, details: typeof body.details === 'string' && body.details !== '' ? body.details : null,
roomId: Number.isInteger(roomId) && roomId > 0 ? roomId : null,
}) })
logger.info('a moderator filed a report by hand', { logger.info('a moderator filed a report by hand', {
reportId: report.id, reportId: report.id,
+63 -4
View File
@@ -662,22 +662,34 @@ it('files a minimal report as the acting moderator', async () => {
const res = await staffPost('/api/staff/reports', 8110, { const res = await staffPost('/api/staff/reports', 8110, {
reportedPlayerId: 8111, reportedPlayerId: 8111,
reportCategory: 102, reportCategory: 102,
details: 'Seen in a log', details: 'Seen in a log, in the Rec Center',
roomId: 991,
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
const report = (await res.json()) as Record<string, unknown> const report = (await res.json()) as Record<string, unknown>
expect(report.reporter_player_id).toBe(8110) expect(report.reporter_player_id).toBe(8110)
expect(report.reported_player_id).toBe(8111) expect(report.reported_player_id).toBe(8111)
expect(report.report_category).toBe(102) expect(report.report_category).toBe(102)
expect(report.details).toBe('Seen in a log') expect(report.details).toBe('Seen in a log, in the Rec Center')
expect(report.room_id).toBe(991)
// Filed unbanned: a report is not a ban, and the ban is a separate decision. // Filed unbanned: a report is not a ban, and the ban is a separate decision.
expect(report.banned).toBe(0) expect(report.banned).toBe(0)
expect(report.banned_at).toBeNull() expect(report.banned_at).toBeNull()
expect(report.banned_by_player_id).toBeNull() expect(report.banned_by_player_id).toBeNull()
}) })
// Who, what kind and why — and nothing else. A moderator does not know a room's numeric
// id and would have to go and look it up, so WHERE goes in `details` with the rest of the
// account of what happened. `room_id` is a player report's column: the client that filed
// one knows the id it was standing in, and a hand-written row must not pretend to.
it('ignores a room id on a hand-written report', async () => {
const res = await staffPost('/api/staff/reports', 8110, {
reportedPlayerId: 8112,
details: 'In someone’s dorm',
roomId: 991,
})
expect(res.status).toBe(200)
expect(((await res.json()) as Record<string, unknown>).room_id).toBeNull()
})
it('refuses a report with no player, and one a moderator files against themselves', async () => { it('refuses a report with no player, and one a moderator files against themselves', async () => {
expect((await staffPost('/api/staff/reports', 8110, {})).status).toBe(400) expect((await staffPost('/api/staff/reports', 8110, {})).status).toBe(400)
// The self-report is a mistake every time, and the ban it would justify locks the // The self-report is a mistake every time, and the ban it would justify locks the
@@ -1027,3 +1039,50 @@ it('previews which other accounts a ban would reach', async () => {
// Never the account asked about: the question is who ELSE. // Never the account asked about: the question is who ELSE.
expect(preview.linked.map((l) => l.accountId)).not.toContain(8220) expect(preview.linked.map((l) => l.accountId)).not.toContain(8220)
}) })
// The panel's own URLs must reach the SPA, not the worker's API surface. `requireStaff`
// is mounted on `/api/staff/*`, and `run_worker_first` lists `/api/*` — so every
// `/moderation/…` path falls through assets-first to the SPA shell, which is what lets a
// moderator reload on a player's history or paste a link to one.
//
// What is asserted is the NEGATIVE: not a 401 or 403. A test run has no ASSETS binding
// (there is no client build behind it), so the catch-all answers 404 here and every other
// SPA route — `/account`, `/login`, `/` — does too. A 401 would mean the staff middleware
// had grown to cover these paths, and a cold load of the panel would start refusing
// instead of rendering.
it('leaves the panel’s own routes to the SPA rather than the staff endpoints', async () => {
for (const path of [
'/moderation',
'/moderation/search',
'/moderation/bans',
'/moderation/players/5',
'/moderation/reports/5/ban',
]) {
const res = await SELF.fetch(`https://example.com${path}`)
expect(res.status).not.toBe(401)
expect(res.status).not.toBe(403)
// Same answer the other SPA routes give in a test run, for the same reason.
expect(res.status).toBe((await SELF.fetch('https://example.com/account')).status)
}
})
// Ordered by when the ban LANDED, newest first: the list's job is catching mistakes, and
// the ban most likely to be wrong is the one just handed down. See `getBansInForce`.
it('serves standing bans most recently handed down first', async () => {
const older = await createReport(env.DB, { reporterPlayerId: 8231, reportedPlayerId: 8230 })
const newer = await createReport(env.DB, { reporterPlayerId: 8233, reportedPlayerId: 8232 })
// The older ban is the PERMANENT one, so an order by severity would float it to the top.
expect(
(await staffPost(`/api/staff/reports/${older.id}/ban`, 8110, { permanent: true })).status
).toBe(200)
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(older.id, '2024-01-01T00:00:00.000Z')
.run()
expect((await staffPost(`/api/staff/reports/${newer.id}/ban`, 8110, { days: 1 })).status).toBe(
200
)
const bans = (await (await staffGet('/api/staff/bans', 8110)).json()) as Array<{ id: number }>
const ordered = bans.map((b) => b.id).filter((id) => [older.id, newer.id].includes(id))
expect(ordered).toEqual([newer.id, older.id])
})