feat(safety): report, block and hide, MODULE_API 1.21.0
USER_SAFETY.md step 2 (core), with S11-S14 decided while building it. - Reports: POST /player/reports takes any target type, core's forum types or a module's registered ones. One resolver (model/safety/targets.js) answers "what is this item, for this reader" and 404s an item the reader cannot see. Every report stores content_reports.snapshot; the queue shows a module item from it. - Hide for everyone: POST /admin/moderation/reports/:id/hide hides the item through its own moderation (forum hide as staff, or the module's hide) and actions the report with actioned_with = 'hide'. - Blocks (user_blocks) name an item, never a person, and land on the author's account first (S12); an unlinked game identity is blocked as itself and becomes an account block once linked (S14). Staff cannot be blocked; a block on someone later promoted stops applying (S8). - Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden. - Forum reads filter blocked authors and hidden items on the server and carry canBlock per thread and post. - MODULE_API 1.21.0: api.registerReportTargets, api.registerGameIdentities, ctx.safety.forViewer and ctx.safety.staffAmong, so a module filters its own per-viewer feeds on the server (S11) and marks lines canBlock (S13). - Web: Hide for me / Block author in the forum menus, a Blocked section on both account pages, Hide for everyone in the Reports queue. - Tests, OpenAPI, route/guard/engagement manifests regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -154,6 +154,20 @@ export const api = {
|
||||
generateRecoveryCodes: (currentPassword) =>
|
||||
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
|
||||
|
||||
// Report, block and "Hide for me" (USER_SAFETY.md §4.1). A block and a report
|
||||
// name an ITEM — `{ targetType, targetId }` — never a person: the server reads
|
||||
// who wrote it. Saved on the account, so they apply on every device.
|
||||
reportItem: ({ targetType, targetId, reason, detail }) =>
|
||||
req('/player/reports', { method: 'POST', body: { targetType, targetId, reason, detail } }),
|
||||
myBlocks: () => req('/auth/me/blocks'),
|
||||
blockAuthor: ({ targetType, targetId }) => req('/auth/me/blocks', { method: 'POST', body: { targetType, targetId } }),
|
||||
unblock: (id) => req(`/auth/me/blocks/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
myHidden: () => req('/auth/me/hidden'),
|
||||
hideItem: ({ targetType, targetId }) => req('/auth/me/hidden', { method: 'POST', body: { targetType, targetId } }),
|
||||
unhideItem: ({ targetType, targetId }) =>
|
||||
req(`/auth/me/hidden/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`, { method: 'DELETE' }),
|
||||
clearHidden: () => req('/auth/me/hidden', { method: 'DELETE' }),
|
||||
|
||||
// ----- settings (any authenticated account) -----
|
||||
// Nav overrides for the layouts the caller's own role renders, and the theme
|
||||
// catalog the appearance form is built from. A fifth group, not part of
|
||||
@@ -656,6 +670,10 @@ export const api = {
|
||||
},
|
||||
handleContentReport: (id, body) =>
|
||||
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
|
||||
// "Hide for everyone" (USER_SAFETY.md §4.1): hides the item through its own
|
||||
// moderation and actions the report.
|
||||
hideReportedItem: (id, body = {}) =>
|
||||
req(`/admin/moderation/reports/${id}/hide`, { method: 'POST', body }),
|
||||
modRecent: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
|
||||
119
client/src/components/security/BlockedPanel.jsx
Normal file
119
client/src/components/security/BlockedPanel.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
import { blockSubtitle } from '../../lib/userSafety.js'
|
||||
|
||||
// Account → Blocked (docs/website/USER_SAFETY.md §1, §4.1). The members and
|
||||
// players this account has blocked, each with Unblock, and the items it hid for
|
||||
// itself, with one "Show them again".
|
||||
//
|
||||
// Blocks and hides are saved on the account, so this list is the same one the
|
||||
// app shows, and undoing one here undoes it everywhere. The role-agnostic
|
||||
// /auth/me surface, so the same panel serves players and staff.
|
||||
//
|
||||
// Two kinds of block, and the panel names which: an account (a forum author, or
|
||||
// a chat speaker whose game account is linked — S12) or a game identity nobody
|
||||
// has linked yet (a Steam id). A block whose target has since become staff no
|
||||
// longer applies (S8), and the row says so rather than the block vanishing.
|
||||
|
||||
export default function BlockedPanel() {
|
||||
const [blocks, setBlocks] = useState(null)
|
||||
const [hiddenCount, setHiddenCount] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [b, h] = await Promise.all([api.myBlocks(), api.myHidden()])
|
||||
setBlocks(b.blocks || [])
|
||||
setHiddenCount((h.hidden || []).length)
|
||||
} catch {
|
||||
setError('Could not load your blocks.')
|
||||
setBlocks([])
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function unblock(block) {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.unblock(block.id)
|
||||
setMsg(block.name ? `${block.name} is unblocked.` : 'Unblocked.')
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not unblock that.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function showHidden() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.clearHidden()
|
||||
setMsg('Everything you hid is shown again.')
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not show those again.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!blocks) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Blocked
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
You don’t see anything a blocked member or player writes — forum posts and in-game chat — on this
|
||||
site or in the app. They aren’t told. Staff can’t be blocked.
|
||||
</p>
|
||||
|
||||
{blocks.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{blocks.map((b) => (
|
||||
<div key={b.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{b.name || 'Unknown player'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{blockSubtitle(b)}
|
||||
</div>
|
||||
{!b.applies && b.reason === 'staff' && (
|
||||
<div className="sans" style={{ fontSize: '0.76rem', color: '#d9b884', marginTop: 2 }}>
|
||||
Not applied: this account is now a staff member, and staff can’t be blocked.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => unblock(b)} disabled={busy} className="pill">
|
||||
Unblock
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>You haven’t blocked anyone.</p>
|
||||
)}
|
||||
|
||||
{hiddenCount > 0 && (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.86rem' }}>
|
||||
You’ve hidden {hiddenCount} {hiddenCount === 1 ? 'item' : 'items'} for yourself.
|
||||
</span>
|
||||
<button onClick={showHidden} disabled={busy} className="pill">Show them again</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
client/src/lib/userSafety.js
Normal file
35
client/src/lib/userSafety.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Report, block and hide — the parts of the screens that are rules rather than
|
||||
// markup (docs/website/USER_SAFETY.md §1, §4.1). Plain JS so the client's
|
||||
// node --test suite can hold them.
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of a row on Account → Blocked: what was blocked, and when.
|
||||
* An account block is the normal one (S12); a game block names the identity
|
||||
* and says it is not linked to an account yet, since that is why it is a Steam
|
||||
* id and not a username.
|
||||
*/
|
||||
export function blockSubtitle(block) {
|
||||
const parts = []
|
||||
if (block.kind === 'game' && block.identity) {
|
||||
parts.push(`${block.identity.label} ${block.identity.id}, not linked to an account`)
|
||||
} else {
|
||||
parts.push('Account')
|
||||
}
|
||||
const when = fmtDate(block.createdAt)
|
||||
if (when) parts.push(`blocked ${when}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* May the Reports queue offer "Hide for everyone" on this report? Not on an
|
||||
* upload — it is shown inside a post, and hiding that post is the act — and not
|
||||
* once this report has already hidden its item.
|
||||
*/
|
||||
export const canHideForEveryone = (report) =>
|
||||
report.targetType !== 'team_forum_upload' && report.actionedWith !== 'hide'
|
||||
@@ -126,6 +126,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
||||
imageMode={imageMode}
|
||||
onBack={() => openThread(null)}
|
||||
onChanged={() => loadThread(team.slug, thread.id)}
|
||||
onGone={async () => {
|
||||
await loadThreads(team.slug)
|
||||
openThread(null)
|
||||
}}
|
||||
onModerate={async (action) => {
|
||||
await api.teamForumModerate(team.slug, thread.id, { action })
|
||||
await loadThreads(team.slug)
|
||||
@@ -311,7 +315,7 @@ function GuestManager({ slug }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
|
||||
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onGone, onModerate }) {
|
||||
// A clock that ticks, so an edit control whose deadline passed while the page
|
||||
// sat open goes away instead of becoming a button that fails. It only ever
|
||||
// REMOVES an offer — the server decides whether an edit happens, and re-derives
|
||||
@@ -386,6 +390,15 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
|
||||
targetId={thread.id}
|
||||
label="Report this thread"
|
||||
/>
|
||||
{/* A thread this reader hides, or whose author they block, is gone
|
||||
from their view, so the panel goes back to the list. */}
|
||||
<SafetyControls
|
||||
targetType="team_forum_thread"
|
||||
targetId={thread.id}
|
||||
author={thread.author}
|
||||
canBlock={thread.canBlock}
|
||||
onDone={onGone}
|
||||
/>
|
||||
{canModerate && (
|
||||
<>
|
||||
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
|
||||
@@ -515,6 +528,15 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||
label="Report"
|
||||
/>
|
||||
)}
|
||||
{!post.mine && (
|
||||
<SafetyControls
|
||||
targetType="team_forum_post"
|
||||
targetId={post.id}
|
||||
author={post.author}
|
||||
canBlock={post.canBlock}
|
||||
onDone={onChanged}
|
||||
/>
|
||||
)}
|
||||
{canModerate && (
|
||||
<>
|
||||
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||
@@ -529,6 +551,76 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Hide for me" and "Block author" (USER_SAFETY.md §1).
|
||||
*
|
||||
* Both are the reader's own tools and both are saved on their account, so they
|
||||
* hold on every device and in the app. Neither files a report or changes the
|
||||
* item for anyone else, and the blocked person is not told (S9) — the
|
||||
* confirmation says so, because a member deciding whether to block someone is
|
||||
* usually deciding whether it will start something.
|
||||
*
|
||||
* `canBlock` is the server's answer (S8: never yourself, a deleted account or
|
||||
* staff); this offers Block only when it is true. "Hide for me" works on any
|
||||
* single item, a staff member's included.
|
||||
*/
|
||||
function SafetyControls({ targetType, targetId, author, canBlock, onDone }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const run = async (call) => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await call({ targetType, targetId })
|
||||
setConfirming(false)
|
||||
await onDone()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not do that')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
|
||||
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<p className="sans" style={{ fontSize: '0.85rem', margin: 0 }}>
|
||||
Block <strong>{author}</strong>? You won’t see anything they write, here or in the app.
|
||||
They aren’t told. You can undo it from <em>Account → Blocked</em>.
|
||||
</p>
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem', margin: 0 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => run(api.blockAuthor)}>
|
||||
Block
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => setConfirming(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="pill" disabled={busy} onClick={() => run(api.hideItem)}>
|
||||
Hide for me
|
||||
</button>
|
||||
{canBlock && (
|
||||
<button type="button" className="pill" onClick={() => setConfirming(true)}>
|
||||
Block author
|
||||
</button>
|
||||
)}
|
||||
{error && <span className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The report control — the first user-facing report flow this site has ever had.
|
||||
*
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.21.0 — report, block and hide reach a module's content (USER_SAFETY.md
|
||||
// §4.1): `api.registerReportTargets`, `api.registerGameIdentities`,
|
||||
// `ctx.safety`. Server-side: a module filters its own feeds per reader on the
|
||||
// server (S11), and its client half calls core's ordinary `/player/reports` and
|
||||
// `/auth/me/blocks` routes through the shared request primitive. This file bumps
|
||||
// for the reason at the top.
|
||||
// 1.20.0 — `api.onUserDeleting` (ACCOUNT_DELETION.md §4.1, AD10): a module
|
||||
// unlinks a person's game links before their account is deleted. Server-side
|
||||
// only. This file bumps for the reason at the top.
|
||||
@@ -108,4 +114,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.20.0'
|
||||
export const MODULE_API_VERSION = '1.21.0'
|
||||
|
||||
@@ -6,6 +6,7 @@ import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPane
|
||||
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
|
||||
import DeleteAccountPanel from '../../../components/security/DeleteAccountPanel.jsx'
|
||||
import BlockedPanel from '../../../components/security/BlockedPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
@@ -317,6 +318,10 @@ export default function AccountAdmin() {
|
||||
|
||||
<LinkedAccounts />
|
||||
|
||||
{/* Blocks and hides are the member's own (USER_SAFETY.md §1); staff are
|
||||
members too, and the list is the one the app shows. */}
|
||||
<BlockedPanel />
|
||||
|
||||
{/* The same section the player portal shows: every member can delete
|
||||
themself, staff included (ACCOUNT_DELETION.md §1). */}
|
||||
{account && <DeleteAccountPanel account={account} />}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago, dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { markDeleted } from '../../../lib/deletedAccount.js'
|
||||
import { canHideForEveryone } from '../../../lib/userSafety.js'
|
||||
|
||||
// The member-raised content-report queue (TEAMS.md §5.6).
|
||||
//
|
||||
@@ -21,10 +22,20 @@ import { markDeleted } from '../../../lib/deletedAccount.js'
|
||||
// row here rather than as a new screen.
|
||||
//
|
||||
// **Handling a report is bookkeeping about the REPORT, not moderation of the
|
||||
// content.** Acting on the content itself is the ordinary forum moderation
|
||||
// control, or a site-wide sanction against the account. Keeping those separate is
|
||||
// what stops "report" from becoming a way for any member to hide anything, so
|
||||
// this screen deliberately offers no hide/delete button of its own.
|
||||
// content.** Keeping those separate is what stops "report" from becoming a way
|
||||
// for any member to hide anything.
|
||||
//
|
||||
// **The one exception is "Hide for everyone"** (USER_SAFETY.md §4.1, S3), and it
|
||||
// is a separate button with its own words, not a status. It hides the item
|
||||
// through the item's OWN moderation — the forum's hide, or a game module's for a
|
||||
// chat line — so it is a staff moderation recorded where that content's
|
||||
// moderation is recorded, and the report is actioned with that named. Undoing it
|
||||
// happens where the item lives, where staff still see it marked (S10). Delete
|
||||
// stays the forum's.
|
||||
//
|
||||
// Since 1.21.0 a report can be about a game module's item (a Rust chat line),
|
||||
// and every new report carries a snapshot of what the item said, which is what
|
||||
// this screen shows once the item itself is gone.
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: 'open_work', label: 'Open work', param: undefined },
|
||||
@@ -71,12 +82,44 @@ const bytes = (n) => {
|
||||
* was gone" is a fact worth seeing, and dropping it would hide the pattern of a
|
||||
* member deleting their own content the moment it is reported.
|
||||
*/
|
||||
function SnapshotLine({ snapshot }) {
|
||||
// An unlinked chat speaker is named by their in-game name and Steam id; the
|
||||
// queue is staff-only, and the id is what a staffer acts on in game.
|
||||
const who = markDeleted(snapshot.author, snapshot.authorDeleted) || 'unknown'
|
||||
const idNote = snapshot.authorIdentity ? ` (${snapshot.authorIdentity.kind} ${snapshot.authorIdentity.id})` : ''
|
||||
return (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{snapshot.label ? `${snapshot.label} · ` : ''}{who}{idNote}
|
||||
{snapshot.where?.server ? ` on ${snapshot.where.server}` : ''}
|
||||
{snapshot.at ? ` · ${dateTime(snapshot.at)}` : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetCell({ report }) {
|
||||
const t = report.target
|
||||
const snap = report.snapshot
|
||||
if (!t) {
|
||||
return (
|
||||
<span style={{ color: 'var(--muted)' }}>
|
||||
{report.targetType.replace('team_forum_', '')} #{report.targetId} — no longer exists
|
||||
{/* What it said when it was reported, kept on the report (1.21.0). */}
|
||||
{snap && (
|
||||
<>
|
||||
<span style={{ display: 'block', color: 'var(--text)' }}>{snap.text || <em>(no text)</em>}</span>
|
||||
<SnapshotLine snapshot={snap} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (t.kind === 'module') {
|
||||
return (
|
||||
<span>
|
||||
{t.text || <em className="dim">(no text)</em>}
|
||||
{snap ? <SnapshotLine snapshot={snap} /> : (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>{t.label}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -134,10 +177,11 @@ export default function ContentReports() {
|
||||
return (
|
||||
<section>
|
||||
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
|
||||
Reports raised by members about Team forum content. They come to site staff and are not visible
|
||||
to a Team’s own leaders — a leader moderates their own forum, so a report about a leader
|
||||
has to reach someone above them. Handling a report records a decision about the report; hiding
|
||||
or removing the content itself is done from the forum, or as a sanction against the account.
|
||||
Reports raised by members about Team forum content and in-game chat. They come to site staff and
|
||||
are not visible to a Team’s own leaders — a leader moderates their own forum, so a report
|
||||
about a leader has to reach someone above them. Handling a report records a decision about the
|
||||
report. <em>Hide for everyone</em> also hides the item, for everyone but staff; removing it is
|
||||
done from the forum, or as a sanction against the account.
|
||||
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
|
||||
</p>
|
||||
|
||||
@@ -197,6 +241,9 @@ export default function ContentReports() {
|
||||
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
|
||||
<td className="adm-td">
|
||||
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
|
||||
{r.actionedWith === 'hide' && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>Hidden for everyone</span>
|
||||
)}
|
||||
{r.handledBy && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
|
||||
{markDeleted(r.handledBy, r.handledByDeleted)}
|
||||
@@ -223,9 +270,9 @@ export default function ContentReports() {
|
||||
<HandleModal
|
||||
report={handling}
|
||||
onCancel={() => setHandling(null)}
|
||||
onDone={() => {
|
||||
onDone={(text = 'Report updated.') => {
|
||||
setHandling(null)
|
||||
setNotice({ text: 'Report updated.', tone: 'ok' })
|
||||
setNotice({ text, tone: 'ok' })
|
||||
reload()
|
||||
}}
|
||||
onError={(message) => setNotice({ text: message, tone: 'error' })}
|
||||
@@ -258,6 +305,17 @@ function HandleModal({ report, onCancel, onDone, onError }) {
|
||||
}
|
||||
}
|
||||
|
||||
const hideForEveryone = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.admin.hideReportedItem(report.id, { note: note || undefined })
|
||||
onDone('Hidden for everyone, and the report is actioned.')
|
||||
} catch (err) {
|
||||
onError(err.message || 'Could not hide that.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Report #${report.id}`}
|
||||
@@ -265,6 +323,11 @@ function HandleModal({ report, onCancel, onDone, onError }) {
|
||||
footer={(
|
||||
<>
|
||||
<button className="pill" onClick={onCancel}>Cancel</button>
|
||||
{canHideForEveryone(report) && (
|
||||
<button className="pill" onClick={hideForEveryone} disabled={busy} style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Hide for everyone
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
@@ -273,8 +336,12 @@ function HandleModal({ report, onCancel, onDone, onError }) {
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
|
||||
This records a decision about the report. It does not hide, delete or restore the content —
|
||||
do that from the forum itself, or against the account.
|
||||
<strong>Save</strong> records a decision about the report and changes nothing about the content.
|
||||
{' '}
|
||||
{canHideForEveryone(report)
|
||||
? <><strong>Hide for everyone</strong> hides the item for everyone but staff, who still see it marked
|
||||
where it is and can unhide it there, and actions this report.</>
|
||||
: 'Deleting or restoring the content is done from the forum itself, or against the account.'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.j
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
|
||||
import DeleteAccountPanel from '../../components/security/DeleteAccountPanel.jsx'
|
||||
import BlockedPanel from '../../components/security/BlockedPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
@@ -423,6 +424,7 @@ export default function PlayerAccount() {
|
||||
)}
|
||||
<LinkedAccounts />
|
||||
<ActiveDevices />
|
||||
<BlockedPanel />
|
||||
<DeleteAccountPanel account={account} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -247,6 +247,32 @@ test('the report queue defaults to the open work rather than to everything', asy
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
|
||||
})
|
||||
|
||||
// USER_SAFETY.md §4.1: a report, a block and a hide name an ITEM, never a
|
||||
// person, and the server reads who wrote it.
|
||||
test('report, block and hide name an item, on the one route for every target', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.reportItem({ targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/reports')
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
|
||||
|
||||
calls = []
|
||||
willReply({ body: { block: {} } })
|
||||
await api.blockAuthor({ targetType: 'team_forum_post', targetId: 80 })
|
||||
assert.equal(calls[0].url, '/api/v1/auth/me/blocks')
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'team_forum_post', targetId: 80 })
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.unhideItem({ targetType: 'rust.chat', targetId: 4410 })
|
||||
assert.equal(calls[0].url, '/api/v1/auth/me/hidden/rust.chat/4410')
|
||||
assert.equal(calls[0].opts.method, 'DELETE')
|
||||
|
||||
calls = []
|
||||
willReply({ body: {} })
|
||||
await api.admin.hideReportedItem(41, { note: 'x' })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports/41/hide')
|
||||
})
|
||||
|
||||
test('a Team slug is URL-encoded on every forum path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||
|
||||
24
client/test/userSafety.test.js
Normal file
24
client/test/userSafety.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Account → Blocked and the Reports queue's "Hide for everyone"
|
||||
// (client/src/lib/userSafety.js; docs/website/USER_SAFETY.md §1, §4.1).
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { blockSubtitle, canHideForEveryone } from '../src/lib/userSafety.js'
|
||||
|
||||
test('a game block says it is a Steam id with no account yet; an account block says Account', () => {
|
||||
const game = blockSubtitle({
|
||||
kind: 'game',
|
||||
identity: { module: 'rust', kind: 'steam', label: 'Steam account', id: '76561198000000123' },
|
||||
})
|
||||
assert.equal(game, 'Steam account 76561198000000123, not linked to an account')
|
||||
assert.equal(blockSubtitle({ kind: 'account', identity: null }), 'Account')
|
||||
assert.match(blockSubtitle({ kind: 'account', createdAt: '2026-10-30T10:00:00Z' }), /^Account · blocked /)
|
||||
})
|
||||
|
||||
test('hide for everyone is offered on a post, a thread and a chat line, never on an upload or twice', () => {
|
||||
assert.equal(canHideForEveryone({ targetType: 'team_forum_post', actionedWith: null }), true)
|
||||
assert.equal(canHideForEveryone({ targetType: 'team_forum_thread', actionedWith: null }), true)
|
||||
assert.equal(canHideForEveryone({ targetType: 'rust.chat', actionedWith: null }), true)
|
||||
assert.equal(canHideForEveryone({ targetType: 'team_forum_upload', actionedWith: null }), false)
|
||||
assert.equal(canHideForEveryone({ targetType: 'rust.chat', actionedWith: 'hide' }), false)
|
||||
})
|
||||
Reference in New Issue
Block a user