[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
* list (`www`: `GET /api/staff/bans`).
* Every ban in force right now, MOST RECENTLY HANDED DOWN FIRST — the staff panel's
* standing-bans list (`www`: `GET /api/staff/bans`).
*
* "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,
* 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
* 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(
db: D1Database,
@@ -449,7 +460,7 @@ export async function getBansInForce(
.prepare(
`SELECT * FROM report
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`
)
.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)
})
// 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 () => {
const permanent = await createReport(env.DB, {
reporterPlayerId: 3361,
+41 -14
View File
@@ -441,20 +441,40 @@ const coachMessage = (
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
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(() => {
const onPop = () => setPath(window.location.pathname)
const onPop = () =>
setLocation({ path: window.location.pathname, search: window.location.search })
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [])
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.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
@@ -709,7 +729,7 @@ export function App() {
// 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.
const [config, setConfig] = useState<SiteConfig | undefined>(undefined)
const { path, navigate } = useRouter()
const { path, search, navigate } = useRouter()
const roomId = roomIdFromPath(path)
useEffect(() => {
@@ -768,12 +788,17 @@ export function App() {
// 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.
<ClaimPage account={account} config={config} navigate={navigate} />
) : path === '/moderation' ? (
// A whole surface rather than another dashboard tab, and its own URL so a
// moderator can keep it open. The path is deliberately NOT in wrangler.jsonc's
// `run_worker_first`, so a cold load 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} navigate={navigate} />
) : path === '/moderation' || path.startsWith('/moderation/') ? (
// A whole surface rather than another dashboard tab, and its own URLS: the
// panel routes UNDER this prefix (`/moderation/players/123`, and its filters in
// the query string), so the back button walks back through a moderator's
// session instead of dropping them on the homepage. Matched by prefix here for
// 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 ? (
<RoomPage account={account} roomId={roomId} navigate={navigate} />
) : (
@@ -839,7 +864,9 @@ function NavBar({
<Link
to="/moderation"
navigate={navigate}
className={path === '/moderation' ? 'active' : ''}
className={
path === '/moderation' || path.startsWith('/moderation/') ? 'active' : ''
}
>
Moderation
</Link>
+336 -136
View File
@@ -35,6 +35,43 @@ const TABS: Array<{ id: Tab; label: string }> = [
{ 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
* 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({
account,
path,
search,
navigate,
}: {
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
}) {
const [tab, setTab] = useState<Tab>('queue')
// The player whose history is open, or null for the tab's own view. Local state rather
// than a route: it's a drill-down inside the panel, and a moderator walks in and out of
// several in a sitting without wanting each one in their back button.
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 view = viewForPath(path)
// Bumped after a write that leaves you where you are — lifting a ban from a table. A
// write that NAVIGATES (banning, filing a report) needs no bump: the view it lands on
// mounts fresh and reads for itself.
const [revision, setRevision] = useState(0)
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) {
return (
@@ -306,64 +352,83 @@ export function ModerationPage({
</div>
</section>
{openPlayer !== null ? (
{view.kind === 'player' ? (
<PlayerHistory
playerId={openPlayer}
playerId={view.playerId}
revision={revision}
onBan={setBanning}
onClose={() => setOpenPlayer(null)}
navigate={navigate}
onBan={openBan}
/>
) : view.kind === 'ban' ? (
<BanDialog reportId={view.reportId} navigate={navigate} />
) : (
<div className="workspace">
<nav className="vtabs">
{TABS.map((t) => (
<button
key={t.id}
className={t.id === tab ? 'active' : ''}
onClick={() => setTab(t.id)}
className={t.id === view.tab ? 'active' : ''}
onClick={() => navigate(tabPath(t.id))}
>
{t.label}
</button>
))}
</nav>
<div className="panel">
{tab === 'queue' ? (
<ReportQueue revision={revision} onOpenPlayer={setOpenPlayer} />
) : tab === 'search' ? (
<ReportSearch
{view.tab === 'queue' ? (
<ReportQueue
search={search}
revision={revision}
onOpenPlayer={setOpenPlayer}
onBan={setBanning}
navigate={navigate}
onOpenPlayer={openPlayer}
/>
) : view.tab === 'search' ? (
<ReportSearch
search={search}
revision={revision}
navigate={navigate}
onOpenPlayer={openPlayer}
onBan={openBan}
onChanged={changed}
/>
) : tab === 'bans' ? (
<StandingBans revision={revision} onOpenPlayer={setOpenPlayer} onChanged={changed} />
) : view.tab === 'bans' ? (
<StandingBans revision={revision} onOpenPlayer={openPlayer} onChanged={changed} />
) : (
<FileReport
onFiled={(report) => {
changed()
setBanning(report)
}}
/>
// Filing a report navigates straight to the ban dialog for it — filing one
// is almost always the first half of banning somebody, and the new report's
// id is then in the URL, so the ban is a real page rather than a modal that
// a reload would lose.
<FileReport onFiled={openBan} />
)}
</div>
</div>
)}
{banning !== null && (
<BanDialog
report={banning}
onClose={() => setBanning(null)}
onDone={() => {
setBanning(null)
changed()
}}
/>
)}
</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.
*
@@ -373,14 +438,27 @@ export function ModerationPage({
* is a problem now rather than whoever has ever accumulated the most.
*/
function ReportQueue({
search,
revision,
navigate,
onOpenPlayer,
}: {
search: string
revision: number
navigate: (to: string) => void
onOpenPlayer: (id: number) => void
}) {
const [sinceDays, setSinceDays] = useState('30')
const [minReports, setMinReports] = useState('3')
// In the URL for the same reason the search's filters are: a moderator who widens the
// 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[]>(
`/api/staff/reports/top-reported?sinceDays=${encodeURIComponent(sinceDays)}&minReports=${encodeURIComponent(minReports)}`,
[revision]
@@ -398,7 +476,10 @@ function ReportQueue({
<div className="mod-filters">
<label>
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="30">Last 30 days</option>
<option value="90">Last 90 days</option>
@@ -408,12 +489,20 @@ function ReportQueue({
</label>
<label>
Minimum reports
<input
type="number"
min="1"
{/* A select rather than a number box: each change is now a navigation, and a
free-text field would push a history entry per digit typed — which would
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}
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>
</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 {
reported: string
reporter: string
category: string
reportedPlayerId: string
reporterPlayerId: string
reportCategory: string
banned: string
from: string
to: string
}
const EMPTY_FILTERS: SearchFilters = {
reported: '',
reporter: '',
category: '',
reportedPlayerId: '',
reporterPlayerId: '',
reportCategory: '',
banned: '',
from: '',
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.
*
* 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`'
* 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
* 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({
search,
revision,
navigate,
onOpenPlayer,
onBan,
onChanged,
}: {
search: string
revision: number
navigate: (to: string) => void
onOpenPlayer: (id: number) => void
onBan: (report: ReportRow) => void
onChanged: () => void
}) {
const [form, setForm] = useState<SearchFilters>(EMPTY_FILTERS)
// The query actually in flight, separate from what's typed: a search runs when it is
// submitted, not on every keystroke, and the ids in it are resolved by then.
const [query, setQuery] = useState<string | null>('?take=' + PAGE_SIZE)
const [skip, setSkip] = useState(0)
const applied = filtersFromSearch(search)
const skip = Number.parseInt(new URLSearchParams(search).get('skip') ?? '0', 10) || 0
// What is TYPED, which runs ahead of what is applied — a search happens on submit, not
// on every keystroke. Keyed on the URL so that arriving at a different search (a link,
// 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 path = query === null ? null : `/api/staff/reports${query}&skip=${skip}`
const { data, error } = useStaffData<{ reports: ReportRow[]; total: number }>(path, [revision])
const params = new URLSearchParams({ take: String(PAGE_SIZE), skip: String(skip) })
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 names = useNames(reports.flatMap((r) => [r.reported_player_id, r.reporter_player_id]))
const submit = (e: React.FormEvent) => {
e.preventDefault()
void run(async () => {
const params = new URLSearchParams({ take: String(PAGE_SIZE) })
// Either box may hold a name; resolve before searching so the query is by id, as
// the report rows are.
if (form.reported.trim() !== '') {
params.set('reportedPlayerId', String(await playerIdFrom(form.reported)))
// Either box may hold a name; resolve before navigating so the URL carries the id,
// as the report rows do.
const resolved: SearchFilters = {
...form,
reportedPlayerId:
form.reportedPlayerId.trim() === ''
? ''
: String(await playerIdFrom(form.reportedPlayerId)),
reporterPlayerId:
form.reporterPlayerId.trim() === ''
? ''
: String(await playerIdFrom(form.reporterPlayerId)),
}
if (form.reporter.trim() !== '') {
params.set('reporterPlayerId', String(await playerIdFrom(form.reporter)))
}
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()}`)
// Back to page one: the new filters have their own result set, and staying on
// page three of it would usually show nothing.
navigate(searchUrl(resolved, 0))
return ''
})
}
@@ -550,24 +703,24 @@ function ReportSearch({
<label>
Reported player
<input
value={form.reported}
value={form.reportedPlayerId}
placeholder="@name or id"
onChange={(e) => setForm({ ...form, reported: e.target.value })}
onChange={(e) => setForm({ ...form, reportedPlayerId: e.target.value })}
/>
</label>
<label>
Reported by
<input
value={form.reporter}
value={form.reporterPlayerId}
placeholder="@name or id"
onChange={(e) => setForm({ ...form, reporter: e.target.value })}
onChange={(e) => setForm({ ...form, reporterPlayerId: e.target.value })}
/>
</label>
<label>
Category
<select
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
value={form.reportCategory}
onChange={(e) => setForm({ ...form, reportCategory: e.target.value })}
>
<option value="">Any</option>
{Object.entries(CATEGORY_LABEL).map(([id, label]) => (
@@ -608,14 +761,12 @@ function ReportSearch({
<button type="submit" disabled={pending}>
{pending ? 'Searching…' : 'Search'}
</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
type="button"
className="linkish"
onClick={() => {
setForm(EMPTY_FILTERS)
setSkip(0)
setQuery(`?take=${PAGE_SIZE}`)
}}
onClick={() => navigate(searchUrl(EMPTY_FILTERS, 0))}
>
Clear
</button>
@@ -638,14 +789,22 @@ function ReportSearch({
onBan={onBan}
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">
<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
</button>
<span className="muted">
{skip + 1}–{Math.min(skip + PAGE_SIZE, total)} of {total}
</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
</button>
</div>
@@ -791,8 +950,9 @@ function StandingBans({
<section className="card">
<h2>Standing bans</h2>
<p className="muted">
Bans in force right now, longest-lasting first. An expired ban isn&apos;t here — it has
served its time, and the report stays as the record that it happened.
Bans in force right now, most recent first — so a ban handed down by mistake is at the top,
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>
{error && <p className="error">{error}</p>}
{data === null ? (
@@ -868,20 +1028,22 @@ function StandingBans({
function PlayerHistory({
playerId,
revision,
navigate,
onBan,
onClose,
}: {
playerId: number
revision: number
navigate: (to: string) => 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<{
playerId: number
reports: ReportRow[]
warnings: WarningRow[]
activeBan: ReportRow | null
}>(`/api/staff/players/${playerId}`, [revision])
}>(`/api/staff/players/${playerId}`, [revision, ownRevision])
const reports = data?.reports ?? []
const warnings = data?.warnings ?? []
const names = useNames([
@@ -893,9 +1055,7 @@ function PlayerHistory({
return (
<>
<section className="card">
<button className="linkish" onClick={onClose}>
← Back to the tables
</button>
<BackLink navigate={navigate} label="Back to the tables" />
<h2>
<PlayerName id={playerId} names={names} />
</h2>
@@ -926,7 +1086,12 @@ function PlayerHistory({
{reports.length === 0 ? (
<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>
@@ -1055,8 +1220,7 @@ function FileReport({ onFiled }: { onFiled: (report: ReportRow) => void }) {
const [player, setPlayer] = useState('')
const [category, setCategory] = useState(String(KickReportCategory.Misc))
const [details, setDetails] = useState('')
const [roomId, setRoomId] = useState('')
const { pending, error, done, run } = useAction()
const { pending, error, run } = useAction()
return (
<section className="card">
@@ -1075,14 +1239,13 @@ function FileReport({ onFiled }: { onFiled: (report: ReportRow) => void }) {
reportedPlayerId: await playerIdFrom(player),
reportCategory: Number(category),
details: details.trim(),
roomId: roomId.trim() === '' ? 0 : Number(roomId),
},
})
setPlayer('')
setDetails('')
setRoomId('')
// Straight to the ban page for the new report, which names it — so no
// success line is returned here: this form is gone by the time one could
// be read, and the fields don't need clearing for the same reason.
onFiled(report)
return `Filed report #${report.id}.`
return ''
})
}}
>
@@ -1110,21 +1273,11 @@ function FileReport({ onFiled }: { onFiled: (report: ReportRow) => void }) {
<textarea
value={details}
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)}
/>
</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>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Filing…' : 'File report'}
</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
* instance they are standing in, which is the part that is not obvious from "ban".
*/
function BanDialog({
report,
onClose,
onDone,
}: {
report: ReportRow
onClose: () => void
onDone: () => void
}) {
function BanDialog({ reportId, navigate }: { reportId: number; navigate: (to: string) => void }) {
const [choice, setChoice] = useState('7')
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 (
<section className="card mod-dialog">
<BackLink navigate={navigate} label="Back to the tables" />
<h2>
Ban <PlayerName id={report.reported_player_id} names={names} />
</h2>
@@ -1193,7 +1383,10 @@ function BanDialog({
? { banned: true, permanent: true }
: { 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 ''
})
}}
@@ -1218,7 +1411,14 @@ function BanDialog({
<button type="submit" disabled={pending}>
{pending ? 'Banning…' : 'Apply ban'}
</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
</button>
</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
* 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
* event/invention/item ids all describe a client-side moment that did not happen here, so
* they are not accepted — a hand-written report carries only who, what kind, why, and
* optionally where.
* WHO, WHAT KIND and WHY, and nothing else. Everything else on a report describes a
* client-side moment that did not happen here: the heights the game measured, the instance
* type, the event/invention/item ids — and `room_id`, which a player's report gets from
* 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>) {
const moderatorId = staffId(c)
@@ -263,7 +266,6 @@ export async function createReportHandler(c: Context<App>) {
reportedPlayerId?: unknown
reportCategory?: unknown
details?: unknown
roomId?: unknown
} | null
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)
}
const roomId = Number(body.roomId ?? 0)
const report = await createReport(c.env.DB, {
reporterPlayerId: moderatorId,
reportedPlayerId,
reportCategory: Number.isInteger(Number(body.reportCategory)) ? Number(body.reportCategory) : 0,
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', {
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, {
reportedPlayerId: 8111,
reportCategory: 102,
details: 'Seen in a log',
roomId: 991,
details: 'Seen in a log, in the Rec Center',
})
expect(res.status).toBe(200)
const report = (await res.json()) as Record<string, unknown>
expect(report.reporter_player_id).toBe(8110)
expect(report.reported_player_id).toBe(8111)
expect(report.report_category).toBe(102)
expect(report.details).toBe('Seen in a log')
expect(report.room_id).toBe(991)
expect(report.details).toBe('Seen in a log, in the Rec Center')
// Filed unbanned: a report is not a ban, and the ban is a separate decision.
expect(report.banned).toBe(0)
expect(report.banned_at).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 () => {
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
@@ -1027,3 +1039,50 @@ it('previews which other accounts a ban would reach', async () => {
// Never the account asked about: the question is who ELSE.
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])
})