mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[www] beta mod panel
This commit is contained in:
+34
-208
@@ -12,31 +12,22 @@ import {
|
||||
QUEST_DOWNLOAD_URL,
|
||||
SOURCE_REPO,
|
||||
} from '../links'
|
||||
// The session token, the worker hostnames and the `call` every request goes through —
|
||||
// see api.ts for why they're a module of their own rather than defined here.
|
||||
import {
|
||||
accountIdForUsername,
|
||||
call,
|
||||
hasToken,
|
||||
isAdmin,
|
||||
setHosts,
|
||||
setToken,
|
||||
useAction,
|
||||
where,
|
||||
} from './api'
|
||||
import { ModerationPage } from './Moderation'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* The SPA calls the SAME endpoints the game does — `auth` for tokens and the password
|
||||
* change, `accounts` for the profile, `api` for the photo feed, `notify` for the admin
|
||||
* broadcasts — rather than proxying each one through `www`, exactly as rec.net's own
|
||||
* site did. Those workers answer CORS for it (see their `withDefaultCors()`), and the
|
||||
* access token lives here in the browser.
|
||||
*
|
||||
* `www` serves only two things of its own (see www.app.ts): the config below, and
|
||||
* signup, which is Turnstile-gated and so cannot leave the server.
|
||||
*/
|
||||
|
||||
/** Where each worker lives. From `/api/config`, never baked into this build. */
|
||||
interface Hosts {
|
||||
auth: string
|
||||
accounts: string
|
||||
api: string
|
||||
img: string
|
||||
notify: string
|
||||
rooms: string
|
||||
cdn: string
|
||||
storage: string
|
||||
}
|
||||
import type { Hosts } from './api'
|
||||
|
||||
/**
|
||||
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
|
||||
@@ -167,144 +158,6 @@ const ACCESSIBILITY_LABEL: Record<number, string> = {
|
||||
*/
|
||||
const WEB_PLATFORM = '4'
|
||||
|
||||
/**
|
||||
* The session's access token, in localStorage so a reload stays signed in.
|
||||
*
|
||||
* Readable by page JS, which the httpOnly cookie this replaced was not — that is the
|
||||
* tradeoff that comes with the browser calling the workers itself, and it's the same
|
||||
* posture the game client has. Nothing third-party runs on this origin except the
|
||||
* Turnstile widget, which is Cloudflare's own.
|
||||
*/
|
||||
const TOKEN_KEY = 'rf_token'
|
||||
let token: string | null = localStorage.getItem(TOKEN_KEY)
|
||||
|
||||
function setToken(next: string | null) {
|
||||
token = next
|
||||
if (next === null) localStorage.removeItem(TOKEN_KEY)
|
||||
else localStorage.setItem(TOKEN_KEY, next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled in once `/api/config` lands, before any worker call is made — a module value
|
||||
* rather than a prop threaded through every form, since the components that call a
|
||||
* worker only render after the config resolves.
|
||||
*/
|
||||
let hosts: Hosts | null = null
|
||||
|
||||
/** The hostnames, once known. Throws rather than guessing a domain. */
|
||||
function where(): Hosts {
|
||||
if (hosts === null) throw new Error('Still starting up — please reload the page.')
|
||||
return hosts
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles that unlock the admin controls. Mirrors the notify worker's `ADMIN_ROLES` gate —
|
||||
* this only decides whether to SHOW them; notify verifies the token on every call.
|
||||
*/
|
||||
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
||||
|
||||
/**
|
||||
* Whether the session token carries an admin role. Decodes the `role` claim WITHOUT
|
||||
* verifying it — a page holds no signing key, and faking one here only reveals buttons
|
||||
* whose endpoints reject the same token. A malformed token reads as "not admin".
|
||||
*/
|
||||
function isAdmin(): boolean {
|
||||
const payload = token?.split('.')[1]
|
||||
if (!payload) return false
|
||||
try {
|
||||
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
|
||||
const claims = JSON.parse(atob(padded)) as { role?: unknown }
|
||||
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An OAuth machine code (`invalid_grant`, `server_error`) rather than a sentence — a
|
||||
* lower_snake_case word with no spaces. A worker that speaks OAuth puts one of these in
|
||||
* `error`, where the readable reason is in `error_description`.
|
||||
*/
|
||||
const isErrorCode = (s: string) => /^[a-z][a-z\d]*(_[a-z\d]+)+$/.test(s)
|
||||
|
||||
/**
|
||||
* The message worth showing for a refusal. `error` wins, since that's where a worker
|
||||
* puts a sentence it wrote for the player — but NOT when it's a bare OAuth code, which
|
||||
* tells nobody anything. Some refusals carry no body at all (accounts answers a
|
||||
* malformed email with an empty 400), hence the last-resort line.
|
||||
*/
|
||||
function errorMessage(data: Record<string, unknown>, status: number): string {
|
||||
const error = typeof data.error === 'string' ? data.error : ''
|
||||
const description = typeof data.error_description === 'string' ? data.error_description : ''
|
||||
return (
|
||||
(error && !(isErrorCode(error) && description) && error) ||
|
||||
description ||
|
||||
error ||
|
||||
`Request failed (${status})`
|
||||
)
|
||||
}
|
||||
|
||||
interface CallOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT'
|
||||
/** Form fields — auth and accounts read their input with Hono's `parseBody()`. */
|
||||
form?: Record<string, string>
|
||||
/** A JSON body — what notify's internal endpoints take instead. */
|
||||
json?: unknown
|
||||
/**
|
||||
* A multipart body — what `storage`'s `/upload` takes, since it carries a file. Passed
|
||||
* to `fetch` as-is: the browser writes the `content-type` itself, because only it
|
||||
* knows the boundary it generated.
|
||||
*/
|
||||
multipart?: FormData
|
||||
/** Send the session token. */
|
||||
authed?: boolean
|
||||
/**
|
||||
* What to say when the worker refuses with a 400 and NO body. Several accounts routes
|
||||
* do exactly that (email, display name, bio), so without this the player reads
|
||||
* "Request failed (400)" — the status, not the reason.
|
||||
*/
|
||||
refusal?: string
|
||||
}
|
||||
|
||||
/** Call a worker. Returns the parsed body, or throws with something worth showing. */
|
||||
async function call<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (opts.authed && token) headers.authorization = `Bearer ${token}`
|
||||
let body: string | FormData | undefined
|
||||
if (opts.form) {
|
||||
headers['content-type'] = 'application/x-www-form-urlencoded'
|
||||
body = new URLSearchParams(opts.form).toString()
|
||||
} else if (opts.json !== undefined) {
|
||||
headers['content-type'] = 'application/json'
|
||||
body = JSON.stringify(opts.json)
|
||||
} else if (opts.multipart) {
|
||||
// Deliberately no content-type: setting one would omit the boundary.
|
||||
body = opts.multipart
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: opts.method ?? (body === undefined ? 'GET' : 'POST'),
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
|
||||
if (!res.ok) {
|
||||
// Expired or revoked. Cleared here so no caller has to remember to.
|
||||
if (res.status === 401 && opts.authed) {
|
||||
setToken(null)
|
||||
throw new Error('Your session has expired. Please sign in again.')
|
||||
}
|
||||
// Only when the body really is empty — a worker that did send a reason keeps it.
|
||||
if (opts.refusal !== undefined && res.status === 400 && Object.keys(data).length === 0) {
|
||||
throw new Error(opts.refusal)
|
||||
}
|
||||
throw new Error(errorMessage(data, res.status))
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
/** The signed-in account, straight from `accounts`. */
|
||||
const fetchMe = (): Promise<SelfAccount> =>
|
||||
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
|
||||
@@ -586,28 +439,6 @@ const coachMessage = (
|
||||
authed: true,
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve an `@username` to the account id the workers address a player by.
|
||||
*
|
||||
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and
|
||||
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and
|
||||
* sending a message to whoever happened to sort first would be worse than refusing. The
|
||||
* exact name always sorts first among its own prefixes, so it's inside the search limit
|
||||
* whenever it exists.
|
||||
*/
|
||||
async function accountIdForUsername(input: string): Promise<number> {
|
||||
const name = input.trim().replace(/^@/, '')
|
||||
if (name === '') throw new Error('Enter a username to send to.')
|
||||
const matches = await call<Array<{ accountId?: number; username?: string }>>(
|
||||
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
|
||||
)
|
||||
const found = Array.isArray(matches)
|
||||
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
|
||||
: undefined
|
||||
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
|
||||
return found.accountId
|
||||
}
|
||||
|
||||
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
|
||||
function useRouter() {
|
||||
const [path, setPath] = useState(() => window.location.pathname)
|
||||
@@ -887,9 +718,9 @@ export function App() {
|
||||
// closed rather than guessing where the workers are.
|
||||
call<SiteConfig & { hosts: Hosts }>('/api/config')
|
||||
.then(async ({ hosts: resolved, ...site }) => {
|
||||
hosts = resolved
|
||||
setHosts(resolved)
|
||||
setConfig(site)
|
||||
if (token === null) return setAccount(null)
|
||||
if (!hasToken()) return setAccount(null)
|
||||
// A stored token that `accounts` rejects is stale — `call` has already dropped
|
||||
// it, so this just falls back to signed-out rather than surfacing an error.
|
||||
await fetchMe()
|
||||
@@ -937,6 +768,12 @@ 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} />
|
||||
) : roomId !== null ? (
|
||||
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
||||
) : (
|
||||
@@ -995,6 +832,18 @@ function NavBar({
|
||||
</a>
|
||||
{account === undefined ? null : account ? (
|
||||
<>
|
||||
{/* Staff only, and cosmetic: the link is hidden for everyone else, but
|
||||
`/moderation` is a real URL anyone can type — the page and every
|
||||
endpoint behind it check the token's role themselves. */}
|
||||
{isAdmin() && (
|
||||
<Link
|
||||
to="/moderation"
|
||||
navigate={navigate}
|
||||
className={path === '/moderation' ? 'active' : ''}
|
||||
>
|
||||
Moderation
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/account" navigate={navigate} className={path === '/account' ? 'active' : ''}>
|
||||
My account
|
||||
</Link>
|
||||
@@ -1830,29 +1679,6 @@ function VisibilityBadge({ accessibility }: { accessibility: number }) {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small hook wrapping a submit handler with pending/error/success state. */
|
||||
function useAction() {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [done, setDone] = useState('')
|
||||
|
||||
const run = useCallback(async (fn: () => Promise<string>) => {
|
||||
setPending(true)
|
||||
setError('')
|
||||
setDone('')
|
||||
try {
|
||||
setDone(await fn())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { pending, error, done, run }
|
||||
}
|
||||
|
||||
/**
|
||||
* Turnstile's browser API, as much of it as the signup widget uses. Loaded from
|
||||
* Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in
|
||||
|
||||
@@ -0,0 +1,1228 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { KickReportCategory } from '../../../notify/src/notification-payloads'
|
||||
import { accountIdForUsername, call, isAdmin, useAction, usernamesFor } 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' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/** 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<number, string> = {
|
||||
[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 — local time, to the minute, no seconds. */
|
||||
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).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
/**
|
||||
* What kind of thing a report is against. The three 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'
|
||||
return 'Player'
|
||||
}
|
||||
|
||||
/** A player as a name plus id — the id always shown, since ids are what the rows carry. */
|
||||
function PlayerName({ id, names }: { id: number; names: Map<number, PublicAccount> }) {
|
||||
const account = names.get(id)
|
||||
return (
|
||||
<span className="mod-player">
|
||||
{account?.username ? `@${account.username}` : 'unknown'}
|
||||
<span className="muted"> #{id}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T>(path: string | null, deps: unknown[] = []) {
|
||||
const [data, setData] = useState<T | null>(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<T>(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<Map<number, PublicAccount>>(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,
|
||||
navigate,
|
||||
}: {
|
||||
account: { accountId: number; username: string } | null | undefined
|
||||
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 [revision, setRevision] = useState(0)
|
||||
const changed = useCallback(() => setRevision((n) => n + 1), [])
|
||||
|
||||
if (account === undefined) {
|
||||
return (
|
||||
<main className="shell wide">
|
||||
<section className="card">
|
||||
<p className="muted">Checking your session…</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (account === null) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="card">
|
||||
<h1>Moderation</h1>
|
||||
<p className="muted">You need to be signed in to use the moderation tools.</p>
|
||||
<button onClick={() => navigate('/login')}>Sign in</button>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<main className="shell">
|
||||
<section className="card">
|
||||
<h1>Moderation</h1>
|
||||
<p className="muted">
|
||||
This area is for moderators. Your account doesn't have the moderator or developer
|
||||
role.
|
||||
</p>
|
||||
<button onClick={() => navigate('/')}>Back to the homepage</button>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell mod">
|
||||
<section className="card identity">
|
||||
<div className="muted">Moderating as</div>
|
||||
<div className="big">@{account.username}</div>
|
||||
<div className="handle">
|
||||
#{account.accountId} · actions you take here are recorded against this account
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{openPlayer !== null ? (
|
||||
<PlayerHistory
|
||||
playerId={openPlayer}
|
||||
revision={revision}
|
||||
onBan={setBanning}
|
||||
onClose={() => setOpenPlayer(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="workspace">
|
||||
<nav className="vtabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={t.id === tab ? 'active' : ''}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="panel">
|
||||
{tab === 'queue' ? (
|
||||
<ReportQueue revision={revision} onOpenPlayer={setOpenPlayer} />
|
||||
) : tab === 'search' ? (
|
||||
<ReportSearch
|
||||
revision={revision}
|
||||
onOpenPlayer={setOpenPlayer}
|
||||
onBan={setBanning}
|
||||
onChanged={changed}
|
||||
/>
|
||||
) : tab === 'bans' ? (
|
||||
<StandingBans revision={revision} onOpenPlayer={setOpenPlayer} onChanged={changed} />
|
||||
) : (
|
||||
<FileReport
|
||||
onFiled={(report) => {
|
||||
changed()
|
||||
setBanning(report)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{banning !== null && (
|
||||
<BanDialog
|
||||
report={banning}
|
||||
onClose={() => setBanning(null)}
|
||||
onDone={() => {
|
||||
setBanning(null)
|
||||
changed()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
revision,
|
||||
onOpenPlayer,
|
||||
}: {
|
||||
revision: number
|
||||
onOpenPlayer: (id: number) => void
|
||||
}) {
|
||||
const [sinceDays, setSinceDays] = useState('30')
|
||||
const [minReports, setMinReports] = useState('3')
|
||||
const { data, error } = useStaffData<ReportedPlayerTally[]>(
|
||||
`/api/staff/reports/top-reported?sinceDays=${encodeURIComponent(sinceDays)}&minReports=${encodeURIComponent(minReports)}`,
|
||||
[revision]
|
||||
)
|
||||
const names = useNames((data ?? []).map((row) => row.playerId))
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Report queue</h2>
|
||||
<p className="muted">
|
||||
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.
|
||||
</p>
|
||||
<div className="mod-filters">
|
||||
<label>
|
||||
Window
|
||||
<select value={sinceDays} onChange={(e) => setSinceDays(e.target.value)}>
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
{/* The server reads 0 as "no window" — see the handler. */}
|
||||
<option value="0">All time</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Minimum reports
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={minReports}
|
||||
onChange={(e) => setMinReports(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
{data === null ? (
|
||||
!error && <p className="muted">Loading…</p>
|
||||
) : data.length === 0 ? (
|
||||
<p className="muted">Nobody has been reported that many times in this window.</p>
|
||||
) : (
|
||||
<div className="mod-scroll">
|
||||
<table className="mod-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Reports</th>
|
||||
<th>Reporters</th>
|
||||
<th>Last reported</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => (
|
||||
<tr key={row.playerId}>
|
||||
<td>
|
||||
<PlayerName id={row.playerId} names={names} />
|
||||
{row.bannedNow && <span className="badge mod-banned">Banned</span>}
|
||||
</td>
|
||||
<td>{row.reports}</td>
|
||||
<td>{row.distinctReporters}</td>
|
||||
<td>{when(row.lastReportAt)}</td>
|
||||
<td>
|
||||
<button className="linkish" onClick={() => onOpenPlayer(row.playerId)}>
|
||||
History
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The search form's state — mirrors the query params `searchReportsHandler` reads. */
|
||||
interface SearchFilters {
|
||||
reported: string
|
||||
reporter: string
|
||||
category: string
|
||||
banned: string
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: SearchFilters = {
|
||||
reported: '',
|
||||
reporter: '',
|
||||
category: '',
|
||||
banned: '',
|
||||
from: '',
|
||||
to: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the report log.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
function ReportSearch({
|
||||
revision,
|
||||
onOpenPlayer,
|
||||
onBan,
|
||||
onChanged,
|
||||
}: {
|
||||
revision: number
|
||||
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 { 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 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)))
|
||||
}
|
||||
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()}`)
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
const total = data?.total ?? 0
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Search reports</h2>
|
||||
<p className="muted">
|
||||
Every report ever filed, newest first. The player boxes take an id or an @username.
|
||||
</p>
|
||||
<form className="mod-filters" onSubmit={submit}>
|
||||
<label>
|
||||
Reported player
|
||||
<input
|
||||
value={form.reported}
|
||||
placeholder="@name or id"
|
||||
onChange={(e) => setForm({ ...form, reported: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Reported by
|
||||
<input
|
||||
value={form.reporter}
|
||||
placeholder="@name or id"
|
||||
onChange={(e) => setForm({ ...form, reporter: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Category
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{Object.entries(CATEGORY_LABEL).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Ban state
|
||||
<select
|
||||
value={form.banned}
|
||||
onChange={(e) => setForm({ ...form, banned: e.target.value })}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="true">Actioned (banned)</option>
|
||||
<option value="false">Not actioned</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
From
|
||||
<input
|
||||
type="date"
|
||||
value={form.from}
|
||||
onChange={(e) => setForm({ ...form, from: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input
|
||||
type="date"
|
||||
value={form.to}
|
||||
onChange={(e) => setForm({ ...form, to: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="mod-filter-actions">
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="linkish"
|
||||
onClick={() => {
|
||||
setForm(EMPTY_FILTERS)
|
||||
setSkip(0)
|
||||
setQuery(`?take=${PAGE_SIZE}`)
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{formError && <p className="error">{formError}</p>}
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{data === null ? (
|
||||
!error && <p className="muted">Loading…</p>
|
||||
) : reports.length === 0 ? (
|
||||
<p className="muted">No reports match those filters.</p>
|
||||
) : (
|
||||
<>
|
||||
<ReportTable
|
||||
reports={reports}
|
||||
names={names}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
onBan={onBan}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
<div className="mod-pager">
|
||||
<button disabled={skip === 0} onClick={() => setSkip(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)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** An id typed as a number, or an `@username` resolved through `accounts`. */
|
||||
async function playerIdFrom(input: string): Promise<number> {
|
||||
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<number, PublicAccount>
|
||||
onOpenPlayer?: (id: number) => void
|
||||
onBan: (report: ReportRow) => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mod-scroll">
|
||||
<table className="mod-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Filed</th>
|
||||
<th>Against</th>
|
||||
<th>By</th>
|
||||
<th>Kind</th>
|
||||
<th>Category</th>
|
||||
<th>Details</th>
|
||||
<th>Ban</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reports.map((report) => (
|
||||
<tr key={report.id}>
|
||||
<td>{report.id}</td>
|
||||
<td>{when(report.created_at)}</td>
|
||||
<td>
|
||||
{onOpenPlayer ? (
|
||||
<button
|
||||
className="linkish"
|
||||
onClick={() => onOpenPlayer(report.reported_player_id)}
|
||||
>
|
||||
<PlayerName id={report.reported_player_id} names={names} />
|
||||
</button>
|
||||
) : (
|
||||
<PlayerName id={report.reported_player_id} names={names} />
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<PlayerName id={report.reporter_player_id} names={names} />
|
||||
</td>
|
||||
<td>{reportKind(report)}</td>
|
||||
<td>{categoryLabel(report.report_category)}</td>
|
||||
{/* The reporter's own words, untruncated in the title so a long
|
||||
description is readable without leaving the table. */}
|
||||
<td className="mod-details" title={report.details ?? ''}>
|
||||
{report.details ?? <span className="muted">none given</span>}
|
||||
</td>
|
||||
<td>
|
||||
{report.banned === 1 ? (
|
||||
<span className="badge mod-banned">{expiryLabel(report)}</span>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{report.banned === 1 ? (
|
||||
<LiftBanButton report={report} onDone={onChanged} />
|
||||
) : (
|
||||
<button className="linkish" onClick={() => onBan(report)}>
|
||||
Ban…
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<>
|
||||
<button
|
||||
className="linkish"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
if (!confirm(`Lift the ban on player #${report.reported_player_id}?`)) return ''
|
||||
await call(`/api/staff/reports/${report.id}/ban`, {
|
||||
authed: true,
|
||||
json: { banned: false },
|
||||
})
|
||||
onDone()
|
||||
return ''
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? 'Lifting…' : 'Lift ban'}
|
||||
</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Every ban in force right now. */
|
||||
function StandingBans({
|
||||
revision,
|
||||
onOpenPlayer,
|
||||
onChanged,
|
||||
}: {
|
||||
revision: number
|
||||
onOpenPlayer: (id: number) => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const { data, error } = useStaffData<ReportRow[]>('/api/staff/bans', [revision])
|
||||
const bans = data ?? []
|
||||
const names = useNames(bans.flatMap((b) => [b.reported_player_id, b.banned_by_player_id ?? 0]))
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Standing bans</h2>
|
||||
<p className="muted">
|
||||
Bans in force right now, longest-lasting first. An expired ban isn'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 ? (
|
||||
!error && <p className="muted">Loading…</p>
|
||||
) : bans.length === 0 ? (
|
||||
<p className="muted">Nobody is banned.</p>
|
||||
) : (
|
||||
<div className="mod-scroll">
|
||||
<table className="mod-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Report</th>
|
||||
<th>Reason</th>
|
||||
<th>Banned</th>
|
||||
<th>By</th>
|
||||
<th>Lifts</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((ban) => (
|
||||
<tr key={ban.id}>
|
||||
<td>
|
||||
<button
|
||||
className="linkish"
|
||||
onClick={() => onOpenPlayer(ban.reported_player_id)}
|
||||
>
|
||||
<PlayerName id={ban.reported_player_id} names={names} />
|
||||
</button>
|
||||
</td>
|
||||
<td>#{ban.id}</td>
|
||||
<td>{categoryLabel(ban.report_category)}</td>
|
||||
{/* `banned_at` where there is one; a ban handed down before that
|
||||
column existed has only the report's own date to show. */}
|
||||
<td>
|
||||
{ban.banned_at === null ? (
|
||||
<span className="muted" title="Recorded before ban timestamps were kept">
|
||||
{when(ban.created_at)}?
|
||||
</span>
|
||||
) : (
|
||||
when(ban.banned_at)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{ban.banned_by_player_id === null ? (
|
||||
<span className="muted">unknown</span>
|
||||
) : (
|
||||
<PlayerName id={ban.banned_by_player_id} names={names} />
|
||||
)}
|
||||
</td>
|
||||
<td>{ban.ban_expires === null ? 'Never' : when(ban.ban_expires)}</td>
|
||||
<td>
|
||||
<LiftBanButton report={ban} onDone={onChanged} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
onBan,
|
||||
onClose,
|
||||
}: {
|
||||
playerId: number
|
||||
revision: number
|
||||
onBan: (report: ReportRow) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { data, error } = useStaffData<{
|
||||
playerId: number
|
||||
reports: ReportRow[]
|
||||
warnings: WarningRow[]
|
||||
activeBan: ReportRow | null
|
||||
}>(`/api/staff/players/${playerId}`, [revision])
|
||||
const reports = data?.reports ?? []
|
||||
const warnings = data?.warnings ?? []
|
||||
const names = useNames([
|
||||
playerId,
|
||||
...reports.map((r) => r.reporter_player_id),
|
||||
...warnings.map((w) => w.moderator_player_id),
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="card">
|
||||
<button className="linkish" onClick={onClose}>
|
||||
← Back to the tables
|
||||
</button>
|
||||
<h2>
|
||||
<PlayerName id={playerId} names={names} />
|
||||
</h2>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{data === null ? (
|
||||
!error && <p className="muted">Loading…</p>
|
||||
) : data.activeBan === null ? (
|
||||
<p className="muted">
|
||||
Not banned. {reports.length} report{reports.length === 1 ? '' : 's'} on file,{' '}
|
||||
{warnings.length} warning{warnings.length === 1 ? '' : 's'} handed down.
|
||||
</p>
|
||||
) : (
|
||||
<p className="ok">
|
||||
Banned on report #{data.activeBan.id} for{' '}
|
||||
{categoryLabel(data.activeBan.report_category)} —{' '}
|
||||
{data.activeBan.ban_expires === null
|
||||
? 'permanently'
|
||||
: `until ${when(data.activeBan.ban_expires)}`}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<LinkedAccountsPanel playerId={playerId} />
|
||||
|
||||
<section className="card">
|
||||
<h2>Reports</h2>
|
||||
{reports.length === 0 ? (
|
||||
<p className="muted">Nobody has reported this player.</p>
|
||||
) : (
|
||||
<ReportTable reports={reports} names={names} onBan={onBan} onChanged={onClose} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Warnings</h2>
|
||||
<p className="muted">
|
||||
Warnings a moderator handed down, from the game's own warning endpoint. Nothing here
|
||||
dispatches them; the rows are the record.
|
||||
</p>
|
||||
{warnings.length === 0 ? (
|
||||
<p className="muted">No warnings on file.</p>
|
||||
) : (
|
||||
<div className="mod-scroll">
|
||||
<table className="mod-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>By</th>
|
||||
<th>Category</th>
|
||||
<th>Shown to the player</th>
|
||||
<th>Internal note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{warnings.map((warning) => (
|
||||
<tr key={warning.id}>
|
||||
<td>{when(warning.created_at)}</td>
|
||||
<td>
|
||||
<PlayerName id={warning.moderator_player_id} names={names} />
|
||||
</td>
|
||||
<td>{categoryLabel(warning.report_category)}</td>
|
||||
<td>{warning.display_reason ?? <span className="muted">—</span>}</td>
|
||||
<td>{warning.moderator_note ?? <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<section className="card">
|
||||
<p className="error">{error}</p>
|
||||
</section>
|
||||
)
|
||||
if (data === null) return null
|
||||
|
||||
const arms = [data.arms.platform && 'shared platform identity', data.arms.ip && 'shared IP']
|
||||
.filter(Boolean)
|
||||
.join(' and ')
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Who a ban would also reach</h2>
|
||||
{arms === '' ? (
|
||||
<p className="muted">
|
||||
Ban-evasion matching is switched off on this server, so a ban reaches exactly the one
|
||||
account.
|
||||
</p>
|
||||
) : data.linked.length === 0 ? (
|
||||
<p className="muted">
|
||||
No other account shares a {arms} with this one — a ban reaches only them.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="muted">
|
||||
Evasion matching is on for {arms}. These accounts would be blocked too:
|
||||
</p>
|
||||
<ul className="mod-linked">
|
||||
{data.linked.map((linked) => (
|
||||
<li key={`${linked.via}-${linked.accountId}-${linked.value}`}>
|
||||
<span className={`badge ${linked.via === 'platform' ? 'live' : ''}`}>
|
||||
{linked.via === 'platform' ? 'Same platform login' : 'Same IP'}
|
||||
</span>{' '}
|
||||
{linked.username ? `@${linked.username}` : 'unknown'}
|
||||
<span className="muted">
|
||||
{' '}
|
||||
#{linked.accountId} · {linked.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data.arms.ip && (
|
||||
<p className="muted">
|
||||
An IP match is not proof of the same person. Households and shared networks look
|
||||
identical to it.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({ 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()
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>File a report</h2>
|
||||
<p className="muted">
|
||||
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.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void run(async () => {
|
||||
const report = await call<ReportRow>('/api/staff/reports', {
|
||||
authed: true,
|
||||
json: {
|
||||
reportedPlayerId: await playerIdFrom(player),
|
||||
reportCategory: Number(category),
|
||||
details: details.trim(),
|
||||
roomId: roomId.trim() === '' ? 0 : Number(roomId),
|
||||
},
|
||||
})
|
||||
setPlayer('')
|
||||
setDetails('')
|
||||
setRoomId('')
|
||||
onFiled(report)
|
||||
return `Filed report #${report.id}.`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Player
|
||||
<input
|
||||
value={player}
|
||||
placeholder="@name or id"
|
||||
required
|
||||
onChange={(e) => setPlayer(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Category
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
{FILEABLE_CATEGORIES.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{categoryLabel(id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
What happened
|
||||
<textarea
|
||||
value={details}
|
||||
rows={4}
|
||||
placeholder="What you saw, and where you saw it. 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>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The ban lengths offered, in the order the dialog lists them. `null` is permanent. */
|
||||
const BAN_LENGTHS: Array<{ label: string; days: number | null }> = [
|
||||
{ label: '1 day', days: 1 },
|
||||
{ label: '3 days', days: 3 },
|
||||
{ label: '7 days', days: 7 },
|
||||
{ label: '30 days', days: 30 },
|
||||
{ label: '90 days', days: 90 },
|
||||
{ label: 'Permanent', days: null },
|
||||
]
|
||||
|
||||
/**
|
||||
* Hand down a ban on one report.
|
||||
*
|
||||
* Takes a LENGTH rather than a date: a moderator decides "7 days", and the server turns
|
||||
* that into the expiry it stores, so nobody is doing calendar arithmetic over a ban. The
|
||||
* evasion preview is shown here too — this is the moment the blast radius matters.
|
||||
*
|
||||
* 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
|
||||
}) {
|
||||
const [choice, setChoice] = useState('7')
|
||||
const { pending, error, run } = useAction()
|
||||
const names = useNames([report.reported_player_id])
|
||||
|
||||
return (
|
||||
<section className="card mod-dialog">
|
||||
<h2>
|
||||
Ban <PlayerName id={report.reported_player_id} names={names} />
|
||||
</h2>
|
||||
<p className="muted">
|
||||
On report #{report.id} — {categoryLabel(report.report_category)}
|
||||
{report.details ? `: “${report.details}”` : ''}
|
||||
</p>
|
||||
|
||||
<LinkedAccountsPanel playerId={report.reported_player_id} />
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void run(async () => {
|
||||
const length = BAN_LENGTHS.find((l) => String(l.days) === choice)
|
||||
await call(`/api/staff/reports/${report.id}/ban`, {
|
||||
authed: true,
|
||||
// `permanent` rather than an absent duration: the server reads "no
|
||||
// duration" as permanent too, but saying it explicitly means a bug in
|
||||
// this form can't quietly hand out a life ban.
|
||||
json:
|
||||
length?.days === null || length === undefined
|
||||
? { banned: true, permanent: true }
|
||||
: { banned: true, days: length.days },
|
||||
})
|
||||
onDone()
|
||||
return ''
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Length
|
||||
<select value={choice} onChange={(e) => setChoice(e.target.value)}>
|
||||
{BAN_LENGTHS.map((length) => (
|
||||
<option key={length.label} value={String(length.days)}>
|
||||
{length.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted">
|
||||
They are thrown out of the room they're in right now, and refused at matchmaking
|
||||
until the ban lifts. The report stays as the record either way, and a ban can be lifted
|
||||
from the tables.
|
||||
</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="mod-filter-actions">
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Banning…' : 'Apply ban'}
|
||||
</button>
|
||||
<button type="button" className="linkish" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
/**
|
||||
* The SPA's worker plumbing: the session token, where the workers live, and the one
|
||||
* `call` every view makes its requests through.
|
||||
*
|
||||
* Extracted from App.tsx so the moderation panel (Moderation.tsx) can share it — App
|
||||
* RENDERS that panel, so it can't be the module the panel imports from. The token is
|
||||
* module-level mutable state, which belongs in a module of its own rather than in
|
||||
* whichever component happened to define it first.
|
||||
*
|
||||
* The SPA calls the SAME endpoints the game does — `auth` for tokens and the password
|
||||
* change, `accounts` for the profile, `api` for the photo feed, `notify` for the admin
|
||||
* broadcasts — rather than proxying each one through `www`, exactly as rec.net's own site
|
||||
* did. Those workers answer CORS for it (see their `withDefaultCors()`), and the access
|
||||
* token lives here in the browser. `www` serves only what cannot leave the server: the
|
||||
* site config, Turnstile-gated signup, the Discord benefits claim, and the staff
|
||||
* moderation endpoints (see www.app.ts).
|
||||
*/
|
||||
|
||||
/** Where each worker lives. From `/api/config`, never baked into this build. */
|
||||
export interface Hosts {
|
||||
auth: string
|
||||
accounts: string
|
||||
api: string
|
||||
img: string
|
||||
notify: string
|
||||
rooms: string
|
||||
cdn: string
|
||||
storage: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's access token, in localStorage so a reload stays signed in.
|
||||
*
|
||||
* Readable by page JS, which the httpOnly cookie this replaced was not — that is the
|
||||
* tradeoff that comes with the browser calling the workers itself, and it's the same
|
||||
* posture the game client has. Nothing third-party runs on this origin except the
|
||||
* Turnstile widget, which is Cloudflare's own.
|
||||
*/
|
||||
const TOKEN_KEY = 'rf_token'
|
||||
let token: string | null = localStorage.getItem(TOKEN_KEY)
|
||||
|
||||
export function setToken(next: string | null) {
|
||||
token = next
|
||||
if (next === null) localStorage.removeItem(TOKEN_KEY)
|
||||
else localStorage.setItem(TOKEN_KEY, next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session token is stored — what the app asks on startup to decide between
|
||||
* "check who this is" and "signed out". A getter rather than an exported `token`, so the
|
||||
* only way to change it stays {@link setToken} and nothing can drift from localStorage.
|
||||
*/
|
||||
export const hasToken = (): boolean => token !== null
|
||||
|
||||
/**
|
||||
* Filled in once `/api/config` lands, before any worker call is made — a module value
|
||||
* rather than a prop threaded through every form, since the components that call a
|
||||
* worker only render after the config resolves.
|
||||
*/
|
||||
let hosts: Hosts | null = null
|
||||
|
||||
/** Record where the workers live, once `/api/config` has said. */
|
||||
export function setHosts(next: Hosts) {
|
||||
hosts = next
|
||||
}
|
||||
|
||||
/** The hostnames, once known. Throws rather than guessing a domain. */
|
||||
export function where(): Hosts {
|
||||
if (hosts === null) throw new Error('Still starting up — please reload the page.')
|
||||
return hosts
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles that unlock the admin controls. Mirrors the notify worker's `ADMIN_ROLES` gate —
|
||||
* this only decides whether to SHOW them; notify verifies the token on every call.
|
||||
*/
|
||||
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
||||
|
||||
/**
|
||||
* Whether the session token carries an admin role. Decodes the `role` claim WITHOUT
|
||||
* verifying it — a page holds no signing key, and faking one here only reveals buttons
|
||||
* whose endpoints reject the same token. A malformed token reads as "not admin".
|
||||
*/
|
||||
export function isAdmin(): boolean {
|
||||
const payload = token?.split('.')[1]
|
||||
if (!payload) return false
|
||||
try {
|
||||
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
|
||||
const claims = JSON.parse(atob(padded)) as { role?: unknown }
|
||||
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An OAuth machine code (`invalid_grant`, `server_error`) rather than a sentence — a
|
||||
* lower_snake_case word with no spaces. A worker that speaks OAuth puts one of these in
|
||||
* `error`, where the readable reason is in `error_description`.
|
||||
*/
|
||||
const isErrorCode = (s: string) => /^[a-z][a-z\d]*(_[a-z\d]+)+$/.test(s)
|
||||
|
||||
/**
|
||||
* The message worth showing for a refusal. `error` wins, since that's where a worker
|
||||
* puts a sentence it wrote for the player — but NOT when it's a bare OAuth code, which
|
||||
* tells nobody anything. Some refusals carry no body at all (accounts answers a
|
||||
* malformed email with an empty 400), hence the last-resort line.
|
||||
*/
|
||||
function errorMessage(data: Record<string, unknown>, status: number): string {
|
||||
const error = typeof data.error === 'string' ? data.error : ''
|
||||
const description = typeof data.error_description === 'string' ? data.error_description : ''
|
||||
return (
|
||||
(error && !(isErrorCode(error) && description) && error) ||
|
||||
description ||
|
||||
error ||
|
||||
`Request failed (${status})`
|
||||
)
|
||||
}
|
||||
|
||||
interface CallOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT'
|
||||
/** Form fields — auth and accounts read their input with Hono's `parseBody()`. */
|
||||
form?: Record<string, string>
|
||||
/** A JSON body — what notify's internal endpoints take instead. */
|
||||
json?: unknown
|
||||
/**
|
||||
* A multipart body — what `storage`'s `/upload` takes, since it carries a file. Passed
|
||||
* to `fetch` as-is: the browser writes the `content-type` itself, because only it
|
||||
* knows the boundary it generated.
|
||||
*/
|
||||
multipart?: FormData
|
||||
/** Send the session token. */
|
||||
authed?: boolean
|
||||
/**
|
||||
* What to say when the worker refuses with a 400 and NO body. Several accounts routes
|
||||
* do exactly that (email, display name, bio), so without this the player reads
|
||||
* "Request failed (400)" — the status, not the reason.
|
||||
*/
|
||||
refusal?: string
|
||||
}
|
||||
|
||||
/** Call a worker. Returns the parsed body, or throws with something worth showing. */
|
||||
export async function call<T = Record<string, unknown>>(
|
||||
url: string,
|
||||
opts: CallOptions = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (opts.authed && token) headers.authorization = `Bearer ${token}`
|
||||
let body: string | FormData | undefined
|
||||
if (opts.form) {
|
||||
headers['content-type'] = 'application/x-www-form-urlencoded'
|
||||
body = new URLSearchParams(opts.form).toString()
|
||||
} else if (opts.json !== undefined) {
|
||||
headers['content-type'] = 'application/json'
|
||||
body = JSON.stringify(opts.json)
|
||||
} else if (opts.multipart) {
|
||||
// Deliberately no content-type: setting one would omit the boundary.
|
||||
body = opts.multipart
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: opts.method ?? (body === undefined ? 'GET' : 'POST'),
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
|
||||
if (!res.ok) {
|
||||
// Expired or revoked. Cleared here so no caller has to remember to.
|
||||
if (res.status === 401 && opts.authed) {
|
||||
setToken(null)
|
||||
throw new Error('Your session has expired. Please sign in again.')
|
||||
}
|
||||
// Only when the body really is empty — a worker that did send a reason keeps it.
|
||||
if (opts.refusal !== undefined && res.status === 400 && Object.keys(data).length === 0) {
|
||||
throw new Error(opts.refusal)
|
||||
}
|
||||
throw new Error(errorMessage(data, res.status))
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
/** Small hook wrapping a submit handler with pending/error/success state. */
|
||||
export function useAction() {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [done, setDone] = useState('')
|
||||
|
||||
const run = useCallback(async (fn: () => Promise<string>) => {
|
||||
setPending(true)
|
||||
setError('')
|
||||
setDone('')
|
||||
try {
|
||||
setDone(await fn())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { pending, error, done, run }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `@username` to the account id the workers address a player by.
|
||||
*
|
||||
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and
|
||||
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and
|
||||
* acting on whoever happened to sort first would be worse than refusing. The exact name
|
||||
* always sorts first among its own prefixes, so it's inside the search limit whenever it
|
||||
* exists.
|
||||
*/
|
||||
export async function accountIdForUsername(input: string): Promise<number> {
|
||||
const name = input.trim().replace(/^@/, '')
|
||||
if (name === '') throw new Error('Enter a username.')
|
||||
const matches = await call<Array<{ accountId?: number; username?: string }>>(
|
||||
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
|
||||
)
|
||||
const found = Array.isArray(matches)
|
||||
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
|
||||
: undefined
|
||||
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
|
||||
return found.accountId
|
||||
}
|
||||
|
||||
/** A player as `accounts` serves them publicly — what a bulk lookup hands back. */
|
||||
export interface PublicAccount {
|
||||
accountId: number
|
||||
username: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Names for a set of account ids, through `accounts`' bulk lookup — the same endpoint the
|
||||
* game uses to put names on a friends list.
|
||||
*
|
||||
* The staff endpoints deliberately return ids only: a report row stores ids, and joining
|
||||
* names onto it server-side would couple `www`'s moderation reads to a table `auth` owns.
|
||||
* So the page asks the real service instead, in one request for the whole page of results.
|
||||
*
|
||||
* Every requested id comes back — `accounts` synthesizes a default row for one it has
|
||||
* never seen — so a lookup can't silently drop a player and leave a blank cell. A failed
|
||||
* request resolves EMPTY rather than throwing: names are decoration on a moderation table
|
||||
* whose ids are the real content, and losing them must not blank the table.
|
||||
*/
|
||||
export async function usernamesFor(ids: number[]): Promise<Map<number, PublicAccount>> {
|
||||
const unique = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))]
|
||||
if (unique.length === 0) return new Map()
|
||||
const accounts = await call<PublicAccount[]>(
|
||||
`${where().accounts}/account/bulk?id=${unique.join(',')}`
|
||||
).catch(() => [])
|
||||
return new Map(Array.isArray(accounts) ? accounts.map((a) => [a.accountId, a]) : [])
|
||||
}
|
||||
@@ -959,6 +959,7 @@ label {
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -999,6 +1000,7 @@ textarea {
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 0;
|
||||
@@ -1150,3 +1152,151 @@ button[type='submit']:disabled {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Moderation panel --------------------------------------------------- */
|
||||
/*
|
||||
* The staff surface at /moderation (see Moderation.tsx). Deliberately plainer than the
|
||||
* rest of the site: this is a working tool read for minutes at a time, so it trades the
|
||||
* poster's generous spacing for density, and spends no orange except on the actions it
|
||||
* shares with every other form. Nothing here is warmer than the tables it holds.
|
||||
*/
|
||||
|
||||
/* The panel needs the width — six or seven columns don't fit the 880px the account
|
||||
dashboard is sized for, and a moderator is at a desk. */
|
||||
.shell.mod {
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
/* A row of filter controls that wraps instead of stacking: each one is short, and
|
||||
stacked they push the results below the fold. */
|
||||
.mod-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.mod-filters label {
|
||||
margin-bottom: 0;
|
||||
min-width: 150px;
|
||||
flex: 1 1 150px;
|
||||
}
|
||||
|
||||
.mod-filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mod-filter-actions button {
|
||||
margin: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Tables are the one thing allowed to be wider than the page — a report row has more in
|
||||
it than a phone is wide, and wrapping the cells would make it unscannable. */
|
||||
.mod-scroll {
|
||||
overflow-x: auto;
|
||||
margin: 0 -22px;
|
||||
padding: 0 22px;
|
||||
}
|
||||
|
||||
.mod-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mod-table th {
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
padding: 0 12px 8px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.mod-table td {
|
||||
padding: 10px 12px 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.mod-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mod-table button.linkish {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* A name and the id under it. The id is always shown: rows carry ids, names are a
|
||||
lookup on top, and a moderator acting on the wrong account is the failure to avoid. */
|
||||
.mod-player {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The reporter's own words — the one cell allowed to wrap, clamped to a sensible width
|
||||
so a long description doesn't push the actions off the table. Full text in the title. */
|
||||
.mod-details {
|
||||
white-space: normal;
|
||||
max-width: 320px;
|
||||
min-width: 180px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Red, unlike every other badge: a ban is the one state on these tables that someone
|
||||
needs to notice without reading. */
|
||||
.badge.mod-banned {
|
||||
color: var(--error);
|
||||
border-color: color-mix(in srgb, var(--error) 45%, transparent);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.mod-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mod-pager button {
|
||||
margin: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* The evasion preview. A list rather than a table: each line is one account and one
|
||||
reason, and it is read to decide whether to go ahead, not compared column by column. */
|
||||
.mod-linked {
|
||||
list-style: none;
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* The ban form, pinned under the tables rather than floating over them: it carries the
|
||||
evasion preview, which can run to several lines, and a modal that scrolls is worse
|
||||
than a panel that is simply below. The accent border is what marks it as the thing
|
||||
waiting on you. */
|
||||
.mod-dialog {
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--line));
|
||||
}
|
||||
|
||||
.mod-dialog .card {
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.mod-filters label {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user