diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx
index 7e9e274..e3dabf9 100644
--- a/apps/www/src/client/App.tsx
+++ b/apps/www/src/client/App.tsx
@@ -29,7 +29,7 @@ import {
} from './api'
import { DeviceLinkPage } from './DeviceLink'
import { customAvatarItemIdFromPath, ItemPage } from './Item'
-import { ModerationPage } from './Moderation'
+import { ModerationPage, PlayerBanStatus } from './Moderation'
import { StatsPage } from './Stats'
import { StudioAccessPage } from './StudioAccess'
@@ -1412,6 +1412,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
return (
Staff
+
{/* The gifts are developer-only; www refuses them to moderators. */}
{isDeveloper() && (
<>
diff --git a/apps/www/src/client/Moderation.tsx b/apps/www/src/client/Moderation.tsx
index f328bbc..4443db1 100644
--- a/apps/www/src/client/Moderation.tsx
+++ b/apps/www/src/client/Moderation.tsx
@@ -297,6 +297,111 @@ function useStaffData(path: string | null, deps: unknown[] = []) {
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 = {
+ 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 (
+
+ )
+}
+
+/**
+ * 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
+ 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 for{' '}
+ {categoryLabel(ban.report_category)} — {expiry}.
+ >
+ )
+ }
+ const match = data.blockedBy!
+ return (
+ <>
+ Blocked by the ban on (
+ , {categoryLabel(ban.report_category)} —{' '}
+ {expiry}), through {VIA_LABEL[match.via]}.
+ >
+ )
+}
+
+/**
+ * One line for a player's profile page (`/u/`): 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(`/api/staff/players/${playerId}`)
+ const names = useNames(data?.blockedBy ? [data.blockedBy.bannedAccountId] : [])
+ const history = (
+
+ )
+
+ if (error) return
+ )
+}
+
/**
* 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.
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 { data, error } = useStaffData(`/api/staff/players/${playerId}`, [
+ revision,
+ ownRevision,
+ ])
const reports = data?.reports ?? []
const warnings = data?.warnings ?? []
const names = useNames([
@@ -1125,6 +1228,7 @@ function PlayerHistory({
...reports.map((r) => r.reporter_player_id),
...reports.map((r) => r.unbanned_by_player_id ?? 0),
...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
// first, since the report rows below say "Ban again…" and the reason is up here.
@@ -1144,9 +1248,15 @@ function PlayerHistory({
!error &&
)}
@@ -1441,8 +1546,12 @@ function BanDialog({ reportId, navigate }: { reportId: number; navigate: (to: st
is already banned
- Report #{report.id} — {expiryLabel(report)}. Lift it from the tables if this was a
- mistake.
+ Report #{report.id} — {categoryLabel(report.report_category)}
+ {report.details ? `: “${report.details}”` : ''}
+
+
+ Banned {report.ban_expires === null ? 'permanently' : `until ${when(report.ban_expires)}`}
+ . Lift it from the tables if this was a mistake.