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

{error}

+ if (data === null) return

Checking for a ban…

+ return data.activeBan === null && data.blockedBy === null ? ( +

Not banned. {history}

+ ) : ( +

+ {history} +

+ ) +} + /** * 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 &&

Loading…

) : data.activeBan === null ? ( <> + {data.blockedBy !== null && ( +

+ +

+ )}

- Not banned. {reports.length} report{reports.length === 1 ? '' : 's'} on file,{' '} - {warnings.length} warning{warnings.length === 1 ? '' : 's'} handed down. + {data.blockedBy === null ? 'Not banned.' : 'No ban on this account itself.'}{' '} + {reports.length} report{reports.length === 1 ? '' : 's'} on file, {warnings.length}{' '} + warning{warnings.length === 1 ? '' : 's'} handed down.

{lastLifted !== undefined && (

@@ -1157,12 +1267,7 @@ function PlayerHistory({ ) : (

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

)}
@@ -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.