import { useCallback, useEffect, useState } from 'react' import { KickReportCategory } from '../../../notify/src/notification-payloads' import { accountIdForUsername, call, isAdmin, useAction, usernamesFor, where } from './api' import type { PublicAccount } from './api' /** * The staff moderation panel, at `/moderation`. * * Its own file rather than another section of App.tsx: App renders this, so the shared * plumbing both need lives in api.ts (see the note there), and this is a whole surface * rather than one more form on the account dashboard. * * Everything here talks to `www`'s own `/api/staff/*` endpoints (see src/staff.ts) — * except the names, which come from `accounts`' bulk lookup, the same endpoint the game * uses. The staff reads return account IDS; a moderation table whose rows are numbers is * unreadable, and joining names on the server would tie those reads to a table another * worker owns. * * The `isAdmin()` gate below is cosmetic. It decodes the token's `role` claim without * verifying it, because a page holds no signing key — faking it reveals a table that * every endpoint behind it answers 403 to. `requireStaff` is the real gate. */ /** What the panel is showing. The player history is a drill-down from any of them. */ type Tab = 'queue' | 'search' | 'bans' | 'new' const TABS: Array<{ id: Tab; label: string }> = [ // Queue first: "who is a problem right now" is the question someone opens this page // with, where the search is what you use once you know who you're looking at. { id: 'queue', label: 'Report queue' }, { id: 'search', label: 'Search reports' }, { id: 'bans', label: 'Standing bans' }, { id: 'new', label: 'File a report' }, ] const TAB_IDS = new Set(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 * kept in the type so a row can be passed around whole. */ interface ReportRow { id: number reporter_player_id: number reported_player_id: number report_category: number details: string | null room_id: number | null room_instance_type: string | null created_at: string banned: number ban_expires: string | null event_id: number | null invention_id: number | null custom_avatar_item_id: string | null banned_by_player_id: number | null banned_at: string | null chat_message_id: number | null image_id: number | null /** * The reported photo's key on `img`, joined on by `www` (the `report` table stores only * the id). Null for a report that isn't against a photo, or one whose photo is gone. */ image_name?: string | null /** * Who lifted the ban that once stood on this row, and when — null until one is lifted, * and cleared again by a re-ban. A lift wipes the four ban columns, so this pair is the * only thing on the row that says a colleague decided this player belongs back in the * game; without it the row reads as untouched and the next moderator bans them again. */ unbanned_by_player_id: number | null unbanned_at: string | null } /** A moderator-issued warning, as the `warning` table stores it. */ interface WarningRow { id: number moderator_player_id: number warned_player_id: number report_category: number display_reason: string | null moderator_note: string | null created_at: string } /** A row of the report queue — see `getTopReported`. */ interface ReportedPlayerTally { playerId: number reports: number distinctReporters: number lastReportAt: string bannedNow: boolean } /** An account a ban would also reach, and what links it — see `linkedAccounts`. */ interface LinkedAccount { accountId: number username: string | null via: 'platform' | 'ip' value: string } /** * What a report's category means, keyed off the client's own enum so a renumbering moves * these labels with it rather than leaving them quietly wrong. * * The names are the client's, made readable: `CoCSexual` is the code of conduct's sexual * content rule, and a moderator reading a table should not have to know that. An id with * no entry is shown as the bare number — the column is stored verbatim and unmapped (see * the report write), so a build that reports something new must not render a blank. */ const CATEGORY_LABEL: Record = { [KickReportCategory.Moderator]: 'Moderator action', [KickReportCategory.Unknown]: 'Unspecified', [KickReportCategory.Harassment]: 'Harassment', [KickReportCategory.Cheating]: 'Cheating', [KickReportCategory.AFK]: 'Inactive (AFK)', [KickReportCategory.Misc]: 'Game conduct', [KickReportCategory.Underage]: 'Underage', [KickReportCategory.VoteKick]: 'Vote to kick', [KickReportCategory.MisleadingPurchases]: 'Misleading purchases', [KickReportCategory.CoCUnderage]: 'Underage (CoC)', [KickReportCategory.CoCSexual]: 'Sexual content', [KickReportCategory.CoCDiscrimination]: 'Discrimination', [KickReportCategory.CoCTrolling]: 'Griefing / trolling', [KickReportCategory.CoCNameOrProfile]: 'Name or profile', [KickReportCategory.InappropriateClothing]: 'Inappropriate clothing', [KickReportCategory.IssuingInaccurateReports]: 'Inaccurate reports', } /** The categories the file-a-report form offers, in the order it lists them. */ const FILEABLE_CATEGORIES = [ KickReportCategory.CoCDiscrimination, KickReportCategory.CoCSexual, KickReportCategory.CoCTrolling, KickReportCategory.Harassment, KickReportCategory.CoCNameOrProfile, KickReportCategory.CoCUnderage, KickReportCategory.Cheating, KickReportCategory.InappropriateClothing, KickReportCategory.IssuingInaccurateReports, KickReportCategory.Misc, ] const categoryLabel = (id: number): string => CATEGORY_LABEL[id] ?? `Category ${id}` /** How many reports one page of search results holds. */ const PAGE_SIZE = 25 /** * A date as a moderator reads it — ISO 8601 in UTC, to the minute (`2026-09-25T13:04Z`). * Not local time: the moderators are in different countries, and a ban that expires * "01:04 PM" means a different instant to each of them and a different date on either * side of midnight. One unambiguous spelling, the same on every screen. */ function when(iso: string | null): string { if (iso === null) return '—' const parsed = Date.parse(iso) if (Number.isNaN(parsed)) return iso return new Date(parsed).toISOString().slice(0, 16) + 'Z' } /** * What a ban's expiry says on screen. A null expiry is permanent, not missing — the two * read identically as an empty cell, and confusing them is the difference between a * week's ban and a life one. */ const expiryLabel = (report: ReportRow): string => report.banned !== 1 ? '—' : report.ban_expires === null ? 'Permanent' : when(report.ban_expires) /** * A report whose ban a moderator LIFTED — as opposed to one nobody acted on, which the row * otherwise looks identical to. A standing ban is never "lifted": a re-ban clears the pair. */ const banLifted = (report: ReportRow): boolean => report.banned !== 1 && report.unbanned_at !== null /** * "(lifted) by X" — shown wherever a lifted ban would otherwise pass for a clean report. * It is what stops a second moderator undoing the first one's decision without knowing * there was one: the row says who let the player back in, so the question goes to them * rather than straight back to the ban button. Just the who — when it was lifted is on * the row (`unbanned_at`) but not worth the width in a table cell. */ function LiftedBan({ report, names }: { report: ReportRow; names: Map }) { return ( <> lifted{' '} by{' '} {report.unbanned_by_player_id === null ? ( 'staff' ) : ( )} ) } /** * What kind of thing a report is against. The five id columns are mutually exclusive and * a row with none of them is an ordinary player report (see reports-db) — which is the * only way to tell the kinds apart, so it is worth a column of its own. */ function reportKind(report: ReportRow): string { if (report.event_id !== null) return 'Event' if (report.invention_id !== null) return 'Invention' if (report.custom_avatar_item_id !== null) return 'Avatar item' if (report.chat_message_id !== null) return 'Chat message' if (report.image_id != null) return 'Photo' return 'Player' } /** * A player as `@name`. The id is in the title, not on screen: it costs width in every * cell of every table and staff can look it up when they need it. It IS shown while the * name is still loading or for an account with none, so a row is never nameless. */ function PlayerName({ id, names }: { id: number; names: Map }) { const account = names.get(id) return ( {account?.username ? `@${account.username}` : #{id}} ) } /** * The read of a staff endpoint, with the loading and failure states the panel renders. * `data === null` with no error means still in flight. * * A `null` path means "nothing to read yet" and holds whatever was last loaded — the * search uses it so the table doesn't clear itself between submissions. Re-reads when the * path changes (it carries the query string, so that covers every filter change) or when * a caller's dep does: the panel passes a revision counter it bumps after any write, so * banning someone refreshes whichever tables are on screen. */ function useStaffData(path: string | null, deps: unknown[] = []) { const [data, setData] = useState(null) const [error, setError] = useState('') useEffect(() => { if (path === null) return // Guards against the response of a superseded read landing after a newer one — two // filter changes in quick succession would otherwise render whichever finished last. let live = true setError('') call(path, { authed: true }) .then((result) => live && setData(result)) .catch((err: unknown) => live && setError(err instanceof Error ? err.message : String(err))) return () => { live = false } // The caller's deps are spread in deliberately — the lint rule can't see through a // variable-length array, and `path` plus those deps is the whole trigger set. // oxlint-disable-next-line react-hooks/exhaustive-deps }, [path, ...deps]) return { data, error } } /** * Names for every id a page of rows mentions, in one bulk lookup. * * Re-runs when the rows change, and holds the previous names while the next lookup is in * flight, so a table doesn't flash back to bare ids between pages. */ function useNames(ids: number[]) { const [names, setNames] = useState>(new Map()) const key = ids.join(',') useEffect(() => { if (key === '') return let live = true void usernamesFor(key.split(',').map(Number)).then((found) => { // Merged rather than replaced: the panel holds several tables, and a lookup for // one must not blank the names in another. if (live) setNames((prev) => new Map([...prev, ...found])) }) return () => { live = false } }, [key]) return names } 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 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 (

Checking your session…

) } if (account === null) { return (

Moderation

You need to be signed in to use the moderation tools.

) } // A signed-in player who isn't staff. Told plainly rather than shown a sign-in form: // signing in again would change nothing, and a 404 would just make them wonder. if (!isAdmin()) { return (

Moderation

This area is for moderators. Your account doesn't have the moderator or developer role.

) } return (
Moderating as
@{account.username}
#{account.accountId} · actions you take here are recorded against this account
{view.kind === 'player' ? ( ) : view.kind === 'ban' ? ( ) : (
{view.tab === 'queue' ? ( ) : view.tab === 'search' ? ( ) : view.tab === 'bans' ? ( ) : ( // 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. // `?player=` pre-fills who it's against — the Report link on a player's // profile page lands here with it. )}
)}
) } /** * 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 ( ) } /** * The report queue: who has collected the most reports lately. * * Ranked by DISTINCT reporters ahead of raw count (see `getTopReported`), because one * player filing twenty reports against someone they're feuding with is a different thing * from twenty players each filing one. The window defaults to 30 days so the list is who * 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 }) { // 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( `/api/staff/reports/top-reported?sinceDays=${encodeURIComponent(sinceDays)}&minReports=${encodeURIComponent(minReports)}`, [revision] ) const names = useNames((data ?? []).map((row) => row.playerId)) return (

Report queue

Players with the most reports against them, ranked by how many different people reported them. Already-banned players are marked but still listed — a ban that is about to expire is worth seeing.

{error &&

{error}

} {data === null ? ( !error &&

Loading…

) : data.length === 0 ? (

Nobody has been reported that many times in this window.

) : (
{data.map((row) => ( ))}
Player Reports Reporters Last reported
{row.bannedNow && Banned} {row.reports} {row.distinctReporters} {when(row.lastReportAt)}
)}
) } /** * 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 { reportedPlayerId: string reporterPlayerId: string reportCategory: string banned: string from: string to: string } const EMPTY_FILTERS: SearchFilters = { 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. 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 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(applied) const [formKey, setFormKey] = useState(search) if (formKey !== search) { setFormKey(search) setForm(applied) } const { pending, error: formError, run } = useAction() 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, r.unbanned_by_player_id ?? 0, ]) ) const submit = (e: React.FormEvent) => { e.preventDefault() void run(async () => { // 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)), } // 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 '' }) } const total = data?.total ?? 0 return (

Search reports

Every report ever filed, newest first. The player boxes take an id or an @username.

{/* 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. */}
{formError &&

{formError}

} {error &&

{error}

} {data === null ? ( !error &&

Loading…

) : reports.length === 0 ? (

No reports match those filters.

) : ( <> {/* 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. */}
{skip + 1}–{Math.min(skip + PAGE_SIZE, total)} of {total}
)}
) } /** An id typed as a number, or an `@username` resolved through `accounts`. */ async function playerIdFrom(input: string): Promise { const trimmed = input.trim() if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10) return accountIdForUsername(trimmed) } /** The report rows themselves — shared by the search and the player history. */ function ReportTable({ reports, names, onOpenPlayer, onBan, onChanged, }: { reports: ReportRow[] names: Map onOpenPlayer?: (id: number) => void onBan: (report: ReportRow) => void onChanged: () => void }) { return (
{reports.map((report) => ( {/* The reporter's own words, untruncated in the title so a long description is readable without leaving the table. */} ))}
# Filed Against By Kind Category Details Ban
{report.id} {when(report.created_at)} {onOpenPlayer ? ( ) : ( )} {report.image_name ? ( {reportKind(report)} ) : ( reportKind(report) )} {categoryLabel(report.report_category)} {report.details ?? none given} {report.banned === 1 ? ( {expiryLabel(report)} ) : banLifted(report) ? ( ) : ( — )} {report.banned === 1 ? ( ) : ( )}
) } /** * Lift the ban on a report. Confirmed first — it is one click next to a row of them, and * the mistake puts a banned player straight back in the game. */ function LiftBanButton({ report, onDone }: { report: ReportRow; onDone: () => void }) { const { pending, error, run } = useAction() return ( <> {error &&

{error}

} ) } /** Every ban in force right now. */ function StandingBans({ revision, onOpenPlayer, onChanged, }: { revision: number onOpenPlayer: (id: number) => void onChanged: () => void }) { const { data, error } = useStaffData('/api/staff/bans', [revision]) const bans = data ?? [] const names = useNames(bans.flatMap((b) => [b.reported_player_id, b.banned_by_player_id ?? 0])) return (

Standing bans

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't here: it has served its time, and the report stays as the record that it happened.

{error &&

{error}

} {data === null ? ( !error &&

Loading…

) : bans.length === 0 ? (

Nobody is banned.

) : (
{bans.map((ban) => ( {/* `banned_at` where there is one; a ban handed down before that column existed has only the report's own date to show. */} ))}
Player Report Reason Banned By Lifts
#{ban.id} {categoryLabel(ban.report_category)} {ban.banned_at === null ? ( {when(ban.created_at)}? ) : ( when(ban.banned_at) )} {ban.banned_by_player_id === null ? ( unknown ) : ( )} {ban.ban_expires === null ? 'Never' : when(ban.ban_expires)}
)}
) } /** * Everything on file about one player: the reports against them, the warnings handed * down, the ban in force, and who else a ban would reach. * * One screen because it is one decision. A moderator about to ban someone wants the * history and the blast radius in front of them, not in three places. */ function PlayerHistory({ playerId, revision, navigate, onBan, }: { playerId: number revision: number navigate: (to: string) => void onBan: (report: ReportRow) => 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, ownRevision]) const reports = data?.reports ?? [] const warnings = data?.warnings ?? [] const names = useNames([ playerId, ...reports.map((r) => r.reporter_player_id), ...reports.map((r) => r.unbanned_by_player_id ?? 0), ...warnings.map((w) => w.moderator_player_id), ]) // The most recent lift, if any — it is the thing a moderator about to ban needs to know // first, since the report rows below say "Ban again…" and the reason is up here. const lastLifted = reports .filter(banLifted) .sort((a, b) => (a.unbanned_at! < b.unbanned_at! ? 1 : -1))[0] return ( <>

{error &&

{error}

} {data === null ? ( !error &&

Loading…

) : data.activeBan === null ? ( <>

Not banned. {reports.length} report{reports.length === 1 ? '' : 's'} on file,{' '} {warnings.length} warning{warnings.length === 1 ? '' : 's'} handed down.

{lastLifted !== undefined && (

— on report #{lastLifted.id} for{' '} {categoryLabel(lastLifted.report_category)}. Check with them before banning again.

)} ) : (

Banned on report #{data.activeBan.id} for{' '} {categoryLabel(data.activeBan.report_category)} —{' '} {data.activeBan.ban_expires === null ? 'permanently' : `until ${when(data.activeBan.ban_expires)}`} .

)}

Reports

{reports.length === 0 ? (

Nobody has reported this player.

) : ( setOwnRevision((n) => n + 1)} /> )}

Warnings

Warnings a moderator handed down, from the game's own warning endpoint. Nothing here dispatches them; the rows are the record.

{warnings.length === 0 ? (

No warnings on file.

) : (
{warnings.map((warning) => ( ))}
When By Category Shown to the player Internal note
{when(warning.created_at)} {categoryLabel(warning.report_category)} {warning.display_reason ?? —} {warning.moderator_note ?? —}
)}
) } /** * Who ELSE a ban on this player would block. * * The IP arm is coarse by design — households, NAT, campus and carrier networks put * unrelated players behind one address — so a ban can reach people who did nothing. That * trade is the operator's to make, but it should be made with this list in front of them * rather than discovered from a support ticket. */ function LinkedAccountsPanel({ playerId }: { playerId: number }) { const { data, error } = useStaffData<{ arms: { ip: boolean; platform: boolean } linked: LinkedAccount[] }>(`/api/staff/players/${playerId}/linked`) if (error !== '') return (

{error}

) if (data === null) return null const arms = [data.arms.platform && 'shared platform identity', data.arms.ip && 'shared IP'] .filter(Boolean) .join(' and ') return (

Who a ban would also reach

{arms === '' ? (

Ban-evasion matching is switched off on this server, so a ban reaches exactly the one account.

) : data.linked.length === 0 ? (

No other account shares a {arms} with this one — a ban reaches only them.

) : ( <>

Evasion matching is on for {arms}. These accounts would be blocked too:

    {data.linked.map((linked) => (
  • {linked.via === 'platform' ? 'Same platform login' : 'Same IP'} {' '} {linked.username ? `@${linked.username}` : 'unknown'} {' '} #{linked.accountId} · {linked.value}
  • ))}
{data.arms.ip && (

An IP match is not proof of the same person. Households and shared networks look identical to it.

)} )}
) } /** * File a report by hand. * * A ban lives ON a report (see reports-db), so acting on something nobody happened to * report — found in a log, seen first-hand, escalated from Discord — needs a row to hang * it off. The reporter is the acting moderator, taken from the token; that is the record * of who raised it, which is why nothing marks these rows as staff-created. * * Hands the new report straight to the ban dialog, since filing one is almost always the * first half of banning somebody. */ function FileReport({ initialPlayer, onFiled, }: { initialPlayer: string onFiled: (report: ReportRow) => void }) { const [player, setPlayer] = useState(initialPlayer) const [category, setCategory] = useState(String(KickReportCategory.Misc)) const [details, setDetails] = useState('') const { pending, error, run } = useAction() return (

File a report

For something no player reported. It is filed under your account, and opens the ban dialog once saved — you don't have to ban, and the row stands on its own either way.

{ e.preventDefault() void run(async () => { const report = await call('/api/staff/reports', { authed: true, json: { reportedPlayerId: await playerIdFrom(player), reportCategory: Number(category), details: details.trim(), }, }) // 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 '' }) }} >