mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] better ban display
This commit is contained in:
@@ -29,7 +29,7 @@ import {
|
|||||||
} from './api'
|
} from './api'
|
||||||
import { DeviceLinkPage } from './DeviceLink'
|
import { DeviceLinkPage } from './DeviceLink'
|
||||||
import { customAvatarItemIdFromPath, ItemPage } from './Item'
|
import { customAvatarItemIdFromPath, ItemPage } from './Item'
|
||||||
import { ModerationPage } from './Moderation'
|
import { ModerationPage, PlayerBanStatus } from './Moderation'
|
||||||
import { StatsPage } from './Stats'
|
import { StatsPage } from './Stats'
|
||||||
import { StudioAccessPage } from './StudioAccess'
|
import { StudioAccessPage } from './StudioAccess'
|
||||||
|
|
||||||
@@ -1412,6 +1412,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Staff</h2>
|
<h2>Staff</h2>
|
||||||
|
<PlayerBanStatus playerId={account.accountId} navigate={navigate} />
|
||||||
{/* The gifts are developer-only; www refuses them to moderators. */}
|
{/* The gifts are developer-only; www refuses them to moderators. */}
|
||||||
{isDeveloper() && (
|
{isDeveloper() && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -297,6 +297,111 @@ function useStaffData<T>(path: string | null, deps: unknown[] = []) {
|
|||||||
return { data, error }
|
return { data, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A ban that blocks a player, and which arm of `resolveBan` it reached them by. */
|
||||||
|
interface BanMatch {
|
||||||
|
ban: ReportRow
|
||||||
|
via: 'account' | 'platform' | 'ip'
|
||||||
|
bannedAccountId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a linked arm rests on, in a moderator's words. */
|
||||||
|
const VIA_LABEL: Record<BanMatch['via'], string> = {
|
||||||
|
account: 'their own account',
|
||||||
|
platform: 'a shared platform identity',
|
||||||
|
ip: 'a shared IP address',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The player-history endpoint's reply. */
|
||||||
|
interface PlayerHistoryData {
|
||||||
|
playerId: number
|
||||||
|
reports: ReportRow[]
|
||||||
|
warnings: WarningRow[]
|
||||||
|
activeBan: ReportRow | null
|
||||||
|
blockedBy: BanMatch | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ban's report number, linked to the report's page in the panel. */
|
||||||
|
function ReportLink({ report, navigate }: { report: ReportRow; navigate: (to: string) => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linkish"
|
||||||
|
onClick={() => navigate(`/moderation/reports/${report.id}/ban`)}
|
||||||
|
>
|
||||||
|
report #{report.id}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ban standing on a player, if any: their own, or — when their own account is clean —
|
||||||
|
* a linked account's that `auth`/`match` still lock them out with. Null when nothing blocks
|
||||||
|
* them. Shared by the profile line and the history page so the two can't disagree.
|
||||||
|
*/
|
||||||
|
function BanSummary({
|
||||||
|
data,
|
||||||
|
names,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
data: PlayerHistoryData
|
||||||
|
names: Map<number, PublicAccount>
|
||||||
|
navigate: (to: string) => void
|
||||||
|
}) {
|
||||||
|
const ban = data.activeBan ?? data.blockedBy?.ban ?? null
|
||||||
|
if (ban === null) return null
|
||||||
|
const expiry = ban.ban_expires === null ? 'permanently' : `until ${when(ban.ban_expires)}`
|
||||||
|
if (data.activeBan !== null) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Banned on <ReportLink report={ban} navigate={navigate} /> for{' '}
|
||||||
|
{categoryLabel(ban.report_category)} — {expiry}.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const match = data.blockedBy!
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Blocked by the ban on <PlayerName id={match.bannedAccountId} names={names} /> (
|
||||||
|
<ReportLink report={ban} navigate={navigate} />, {categoryLabel(ban.report_category)} —{' '}
|
||||||
|
{expiry}), through {VIA_LABEL[match.via]}.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line for a player's profile page (`/u/<username>`): whether a ban blocks them right
|
||||||
|
* now — their own or a linked account's — and a link to their full history here.
|
||||||
|
*/
|
||||||
|
export function PlayerBanStatus({
|
||||||
|
playerId,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
playerId: number
|
||||||
|
navigate: (to: string) => void
|
||||||
|
}) {
|
||||||
|
const { data, error } = useStaffData<PlayerHistoryData>(`/api/staff/players/${playerId}`)
|
||||||
|
const names = useNames(data?.blockedBy ? [data.blockedBy.bannedAccountId] : [])
|
||||||
|
const history = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linkish"
|
||||||
|
onClick={() => navigate(`/moderation/players/${playerId}`)}
|
||||||
|
>
|
||||||
|
Moderation history
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) return <p className="error">{error}</p>
|
||||||
|
if (data === null) return <p className="muted">Checking for a ban…</p>
|
||||||
|
return data.activeBan === null && data.blockedBy === null ? (
|
||||||
|
<p className="muted">Not banned. {history}</p>
|
||||||
|
) : (
|
||||||
|
<p className="error">
|
||||||
|
<BanSummary data={data} names={names} navigate={navigate} /> {history}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Names for every id a page of rows mentions, in one bulk lookup.
|
* Names for every id a page of rows mentions, in one bulk lookup.
|
||||||
*
|
*
|
||||||
@@ -1112,12 +1217,10 @@ function PlayerHistory({
|
|||||||
}) {
|
}) {
|
||||||
// Bumped by a write that leaves you on this page — lifting a ban from the table below.
|
// Bumped by a write that leaves you on this page — lifting a ban from the table below.
|
||||||
const [ownRevision, setOwnRevision] = useState(0)
|
const [ownRevision, setOwnRevision] = useState(0)
|
||||||
const { data, error } = useStaffData<{
|
const { data, error } = useStaffData<PlayerHistoryData>(`/api/staff/players/${playerId}`, [
|
||||||
playerId: number
|
revision,
|
||||||
reports: ReportRow[]
|
ownRevision,
|
||||||
warnings: WarningRow[]
|
])
|
||||||
activeBan: ReportRow | null
|
|
||||||
}>(`/api/staff/players/${playerId}`, [revision, ownRevision])
|
|
||||||
const reports = data?.reports ?? []
|
const reports = data?.reports ?? []
|
||||||
const warnings = data?.warnings ?? []
|
const warnings = data?.warnings ?? []
|
||||||
const names = useNames([
|
const names = useNames([
|
||||||
@@ -1125,6 +1228,7 @@ function PlayerHistory({
|
|||||||
...reports.map((r) => r.reporter_player_id),
|
...reports.map((r) => r.reporter_player_id),
|
||||||
...reports.map((r) => r.unbanned_by_player_id ?? 0),
|
...reports.map((r) => r.unbanned_by_player_id ?? 0),
|
||||||
...warnings.map((w) => w.moderator_player_id),
|
...warnings.map((w) => w.moderator_player_id),
|
||||||
|
data?.blockedBy?.bannedAccountId ?? 0,
|
||||||
])
|
])
|
||||||
// The most recent lift, if any — it is the thing a moderator about to ban needs to know
|
// 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.
|
// first, since the report rows below say "Ban again…" and the reason is up here.
|
||||||
@@ -1144,9 +1248,15 @@ function PlayerHistory({
|
|||||||
!error && <p className="muted">Loading…</p>
|
!error && <p className="muted">Loading…</p>
|
||||||
) : data.activeBan === null ? (
|
) : data.activeBan === null ? (
|
||||||
<>
|
<>
|
||||||
|
{data.blockedBy !== null && (
|
||||||
|
<p className="error">
|
||||||
|
<BanSummary data={data} names={names} navigate={navigate} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Not banned. {reports.length} report{reports.length === 1 ? '' : 's'} on file,{' '}
|
{data.blockedBy === null ? 'Not banned.' : 'No ban on this account itself.'}{' '}
|
||||||
{warnings.length} warning{warnings.length === 1 ? '' : 's'} handed down.
|
{reports.length} report{reports.length === 1 ? '' : 's'} on file, {warnings.length}{' '}
|
||||||
|
warning{warnings.length === 1 ? '' : 's'} handed down.
|
||||||
</p>
|
</p>
|
||||||
{lastLifted !== undefined && (
|
{lastLifted !== undefined && (
|
||||||
<p className="mod-lifted-note">
|
<p className="mod-lifted-note">
|
||||||
@@ -1157,12 +1267,7 @@ function PlayerHistory({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="ok">
|
<p className="ok">
|
||||||
Banned on report #{data.activeBan.id} for{' '}
|
<BanSummary data={data} names={names} navigate={navigate} />
|
||||||
{categoryLabel(data.activeBan.report_category)} —{' '}
|
|
||||||
{data.activeBan.ban_expires === null
|
|
||||||
? 'permanently'
|
|
||||||
: `until ${when(data.activeBan.ban_expires)}`}
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -1441,8 +1546,12 @@ function BanDialog({ reportId, navigate }: { reportId: number; navigate: (to: st
|
|||||||
<PlayerName id={report.reported_player_id} names={names} /> is already banned
|
<PlayerName id={report.reported_player_id} names={names} /> is already banned
|
||||||
</h2>
|
</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Report #{report.id} — {expiryLabel(report)}. Lift it from the tables if this was a
|
Report #{report.id} — {categoryLabel(report.report_category)}
|
||||||
mistake.
|
{report.details ? `: “${report.details}”` : ''}
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
Banned {report.ban_expires === null ? 'permanently' : `until ${when(report.ban_expires)}`}
|
||||||
|
. Lift it from the tables if this was a mistake.
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => navigate(`/moderation/players/${report.reported_player_id}`)}>
|
<button onClick={() => navigate(`/moderation/players/${report.reported_player_id}`)}>
|
||||||
See their history
|
See their history
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
|||||||
// Imported rather than reimplemented: the SQL belongs with the table, and `banFromReport`
|
// Imported rather than reimplemented: the SQL belongs with the table, and `banFromReport`
|
||||||
// is the same write `api` would do. www owns the ENDPOINTS, not the storage — see the
|
// is the same write `api` would do. www owns the ENDPOINTS, not the storage — see the
|
||||||
// module comment below.
|
// module comment below.
|
||||||
import { banEvasionMatch, linkedAccounts } from '../../api/src/bans-db'
|
import { banEvasionMatch, linkedAccounts, resolveBan } from '../../api/src/bans-db'
|
||||||
import { getCustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
import { getCustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
||||||
import {
|
import {
|
||||||
banBlockDetails,
|
banBlockDetails,
|
||||||
@@ -530,6 +530,11 @@ export async function bansInForceHandler(c: Context<App>) {
|
|||||||
* Everything on file about one player: every report against them, every warning handed
|
* Everything on file about one player: every report against them, every warning handed
|
||||||
* down, and the ban in force if there is one.
|
* down, and the ban in force if there is one.
|
||||||
*
|
*
|
||||||
|
* `activeBan` is the ban on THIS account. `blockedBy` is what `auth` and `match` actually
|
||||||
|
* enforce — the same `resolveBan` under the operator's arms — so it also catches a player
|
||||||
|
* who is locked out by a ban on an account they share a platform identity or an IP with.
|
||||||
|
* Without it, a ban evader's new account read "Not banned" while being unable to play.
|
||||||
|
*
|
||||||
* One call because it is one screen — a moderator deciding what to do about an account
|
* One call because it is one screen — a moderator deciding what to do about an account
|
||||||
* reads all three together, and three round trips would let the page render a decision
|
* reads all three together, and three round trips would let the page render a decision
|
||||||
* out of a partially-loaded history.
|
* out of a partially-loaded history.
|
||||||
@@ -538,16 +543,18 @@ export async function playerHistoryHandler(c: Context<App>) {
|
|||||||
const playerId = Number.parseInt(c.req.param('id') ?? '', 10)
|
const playerId = Number.parseInt(c.req.param('id') ?? '', 10)
|
||||||
if (Number.isNaN(playerId)) return c.json({ error: 'A numeric player id is required' }, 400)
|
if (Number.isNaN(playerId)) return c.json({ error: 'A numeric player id is required' }, 400)
|
||||||
|
|
||||||
const [reports, warnings, activeBan] = await Promise.all([
|
const [reports, warnings, activeBan, blockedBy] = await Promise.all([
|
||||||
getReportsAgainst(c.env.DB, playerId),
|
getReportsAgainst(c.env.DB, playerId),
|
||||||
getWarningsAgainst(c.env.DB, playerId),
|
getWarningsAgainst(c.env.DB, playerId),
|
||||||
getActiveBan(c.env.DB, playerId),
|
getActiveBan(c.env.DB, playerId),
|
||||||
|
resolveBan(c.env.DB, playerId, { arms: armsFor(c.env) }),
|
||||||
])
|
])
|
||||||
return c.json({
|
return c.json({
|
||||||
playerId,
|
playerId,
|
||||||
reports: await withImageNames(c.env.DB, reports),
|
reports: await withImageNames(c.env.DB, reports),
|
||||||
warnings,
|
warnings,
|
||||||
activeBan,
|
activeBan,
|
||||||
|
blockedBy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1482,6 +1482,41 @@ it('previews which other accounts a ban would reach', async () => {
|
|||||||
expect(preview.linked.map((l) => l.accountId)).not.toContain(8220)
|
expect(preview.linked.map((l) => l.accountId)).not.toContain(8220)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A ban evader's new account has no ban of its own, but `auth`/`match` lock it out through
|
||||||
|
// the linked arms — so the history must say so rather than read "Not banned".
|
||||||
|
it('reports a linked account’s ban as what blocks a player', async () => {
|
||||||
|
const seedAccount = (data: Record<string, unknown>) =>
|
||||||
|
env.DB.prepare('INSERT OR REPLACE INTO account (data) VALUES (?1)')
|
||||||
|
.bind(JSON.stringify(data))
|
||||||
|
.run()
|
||||||
|
await seedAccount({ accountId: 8230, username: 'Banned', lastLoginIp: '203.0.113.30' })
|
||||||
|
await seedAccount({ accountId: 8231, username: 'Alt', signupIp: '203.0.113.30' })
|
||||||
|
await seedAccount({ accountId: 8232, username: 'Stranger', signupIp: '203.0.113.31' })
|
||||||
|
const report = await createReport(env.DB, { reporterPlayerId: 8233, reportedPlayerId: 8230 })
|
||||||
|
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 1 })).status).toBe(
|
||||||
|
200
|
||||||
|
)
|
||||||
|
|
||||||
|
type History = {
|
||||||
|
activeBan: { id: number } | null
|
||||||
|
blockedBy: { via: string; bannedAccountId: number; ban: { id: number } } | null
|
||||||
|
}
|
||||||
|
const history = async (id: number) =>
|
||||||
|
(await (await staffGet(`/api/staff/players/${id}`, 8110)).json()) as History
|
||||||
|
|
||||||
|
// The banned account itself: its own ban, matched directly.
|
||||||
|
expect(await history(8230)).toMatchObject({
|
||||||
|
activeBan: { id: report.id },
|
||||||
|
blockedBy: { via: 'account', bannedAccountId: 8230 },
|
||||||
|
})
|
||||||
|
// The alt: no ban of its own, blocked through the shared IP — naming whose ban it is.
|
||||||
|
expect(await history(8231)).toMatchObject({
|
||||||
|
activeBan: null,
|
||||||
|
blockedBy: { via: 'ip', bannedAccountId: 8230, ban: { id: report.id } },
|
||||||
|
})
|
||||||
|
expect(await history(8232)).toMatchObject({ activeBan: null, blockedBy: null })
|
||||||
|
})
|
||||||
|
|
||||||
// The panel's own URLs must reach the SPA, not the worker's API surface. `requireStaff`
|
// The panel's own URLs must reach the SPA, not the worker's API surface. `requireStaff`
|
||||||
// is mounted on `/api/staff/*`, and `run_worker_first` lists `/api/*` — so every
|
// is mounted on `/api/staff/*`, and `run_worker_first` lists `/api/*` — so every
|
||||||
// `/moderation/…` path falls through assets-first to the SPA shell, which is what lets a
|
// `/moderation/…` path falls through assets-first to the SPA shell, which is what lets a
|
||||||
|
|||||||
Reference in New Issue
Block a user