feat(teams): the phase 5 surface — discussion, replies, reports, and two admin screens

241 client tests pass (224 before).

**The forum panel becomes a forum.** It was "Announcements" with one composer;
it now has two, because phase 5 split one server capability into two: `canPost`
means "may open a discussion" and every participant may — a granted guest with no
game character included, which is path 3 doing its job — while `canAnnounce` is
the leader-only half `canPost` used to carry alone. Threads gain replies, an edit
control, per-post moderation and a report control, all still inside the one slot
the module declares, still navigating by `?thread=`.

**Almost nothing here is the client's decision, and the file says so.** `canPost`,
`canAnnounce`, `canReply` and each post's `canEdit`/`editableUntil` are read, not
computed. The one local judgement is a ticking clock that WITHDRAWS an edit offer
whose deadline passed while the page sat open — it can never grant one, because a
time-bounded permission must not take its clock from the party it bounds. That
asymmetry is the first thing client/test/teamForum.test.js asserts.

The panel's pure parts moved to `lib/teamForum.js` so they can be tested without a
browser, following teamActivity.js and teamAdmin.js. Two of them are subtler than
they look:

  * `stripToText` decodes entities AFTER stripping tags, and `&` last of all.
    Decoding first turns an author's literal "<script>" into a real tag the
    strip pass then deletes — silently losing text that was never dangerous.
  * `threadSummary` counts REPLIES, which is one fewer than `postCount`. Showing
    the raw count tells a reader a brand-new thread already has one reply.

**Three admin surfaces.** The forum settings screen gains the edit-window field
(0 = posts permanent once written). The reports queue is a new screen beside
Appeals — under moderation rather than under Teams, because a staffer working a
queue should have one place to work and `target_type` is deliberately open-ended,
so the next reportable thing arrives as a row rather than as another nav entry.
Its copy tells a member where a report lands and that reporting changes nothing,
because a member who expects a post to vanish and watches it stay reports it
again. There is no leader-facing view and there is not meant to be.

And the per-Team forum moderation ledger finally renders: the route and
`api.admin.teamForumModeration()` have both existed since phase 4 with nothing
calling them, which made `actor_role` — the column that keeps a leader's ordinary
housekeeping distinguishable from a staff intervention — readable only from a DB
client.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:08:59 -05:00
parent 128de0ff2e
commit 3f7e61af1c
11 changed files with 1145 additions and 47 deletions

View File

@@ -47,6 +47,7 @@ import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx' import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx'
import Appeals from './routes/admin/views/Appeals.jsx' import Appeals from './routes/admin/views/Appeals.jsx'
import ContentReports from './routes/admin/views/ContentReports.jsx'
// Player portal // Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx' import PlayerLogin from './routes/player/PlayerLogin.jsx'
@@ -163,6 +164,7 @@ export default function App() {
<Route index element={<Moderation />} /> <Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} /> <Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} /> <Route path="appeals" element={<Appeals />} />
<Route path="reports" element={<ContentReports />} />
</Route> </Route>
<Route path="activity" element={<ActivityAdmin />} /> <Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} /> <Route path="bot-activity" element={<BotActivityAdmin />} />

View File

@@ -162,6 +162,21 @@ export const api = {
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }), req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
teamForumModerate: (slug, id, body) => teamForumModerate: (slug, id, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }), req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
// Phase 5 ("5b"). A reply, an edit and post-level moderation are separate
// routes from their thread-level cousins rather than the same route with a
// target kind, because they answer to different rules: a reply is refused by a
// lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all.
teamForumReply: (slug, threadId, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }),
teamForumEditPost: (slug, postId, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }),
teamForumModeratePost: (slug, postId, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }),
// The report goes to SITE STAFF, never to the Team's leaders — the whole point
// of it is a path that routes around a Team's own leadership (TEAMS.md §5.6).
// There is no leader-facing counterpart to this call and there should not be.
teamForumReport: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }),
teamForumUpload: (slug, file) => { teamForumUpload: (slug, file) => {
const fd = new FormData() const fd = new FormData()
fd.append('image', file) fd.append('image', file)
@@ -318,6 +333,18 @@ export const api = {
// ----- moderation dashboard (admin + moderator) ----- // ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'), modSummary: () => req('/admin/moderation/stats/summary'),
// The content-report queue (TEAMS.md §5.6). Under moderation rather than
// under Teams because a staffer working a queue should have one place to
// work, and a report about a forum post is the same job as a report about
// anything else — which is also why `targetType` is open-ended.
contentReports: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.status) qs.set('status', opts.status)
if (opts.teamId) qs.set('teamId', String(opts.teamId))
return req(`/admin/moderation/reports${withQs(qs.toString())}`)
},
handleContentReport: (id, body) =>
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
modRecent: (params = {}) => { modRecent: (params = {}) => {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type) if (params.type) qs.set('type', params.type)

View File

@@ -0,0 +1,85 @@
// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5).
//
// This file is small on purpose. **Almost nothing about the forum is the
// client's to decide**: who may post, who may moderate, whether an image
// renders, and whether a post may be edited are all answered by the server and
// read from the payload. What is left here is the handful of pure functions that
// turn those answers into what a reader sees, and they are extracted so they can
// be tested without a browser.
//
// The one that deserves a second look is `editOfferOpen`. It can only ever take
// an offer AWAY — the server grants the edit and re-derives the window from
// `created_at` when the write arrives. A client that granted one would be
// deciding a time-bounded permission against the clock of the party it bounds.
export const REPORT_REASONS = [
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
['sexual', 'Sexual content'],
['illegal', 'Illegal content'],
['impersonation', 'Impersonation'],
['other', 'Something else'],
]
/**
* Should the Edit control still be offered for this post?
*
* Three states, and the middle one is the reason this exists:
* • the server said no → no offer, and nothing here can create one
* • the server said yes, no deadline (staff) → offer
* • the server said yes with a deadline that has since passed while the page
* sat open → withdraw the offer, rather than leave a button that fails
*/
export function editOfferOpen(post, now = Date.now()) {
if (!post || !post.canEdit) return false
if (!post.editableUntil) return true
const until = new Date(post.editableUntil).getTime()
return Number.isFinite(until) && until > now
}
/**
* Turn a rendered body back into something an author can edit.
*
* The server stores sanitised HTML and generates images at READ time from the
* URLs an author wrote (§5.5.3), so what comes back is not what was typed. The
* `<img>` has to go — it is core's output, not the author's input, and leaving it
* in would let an author "edit" markup they never wrote and cannot control.
* The URL survives as the link text beside it, which is what re-renders.
*/
export function stripToText(html) {
return String(html || '')
.replace(/<img[^>]*>/gi, '')
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
// Entities last: unescaping before tag-stripping would let an escaped
// "&lt;script&gt;" become a real tag the next pass then removes, which is a
// different string from the one the author wrote.
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
// `&amp;` last of all, or "&amp;lt;" would decode two steps into "<".
.replace(/&amp;/g, '&')
.trim()
}
/**
* The one-line summary under a thread's title in the list.
*
* `postCount` counts every post including the opening one, so a discussion's
* REPLY count is one less — and an announcement has no replies to count at all,
* which is why the count is omitted rather than shown as zero.
*/
export function threadSummary(thread) {
const parts = []
if (thread.type === 'announcement') parts.push('Announcement')
parts.push(thread.author)
if (thread.type === 'discussion' && thread.postCount > 1) {
const replies = thread.postCount - 1
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
}
if (thread.status === 'hidden') parts.push('hidden')
return parts.join(' · ')
}

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { api } from '../api/client.js' import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx' import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
// Core's Team forum, rendered into a second slot a MODULE declares // Core's Team forum, rendered into a second slot a MODULE declares
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4). // (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
@@ -28,6 +29,15 @@ import { useSite } from '../contexts/SiteContext.jsx'
// upload control that would otherwise 404. If the two ever disagree, the server // upload control that would otherwise 404. If the two ever disagree, the server
// is right. // is right.
// //
// **Phase 5 added discussion, and with it three capabilities this file must not
// invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are
// computed on the server and read here. In particular the edit window is a
// server decision twice over — the read path stamps `canEdit`/`editableUntil` and
// the write re-derives it — because a time-bounded permission must not take its
// clock from the party it bounds. What this file does with `editableUntil` is
// stop OFFERING an edit whose deadline has passed while the page sat open; it
// never grants one.
//
// Like the feed, everything here degrades to rendering nothing. A 404 from the // Like the feed, everything here degrades to rendering nothing. A 404 from the
// thread list is the ordinary case — the forum is switched off, or this viewer // thread list is the ordinary case — the forum is switched off, or this viewer
// has no access — and putting an error box on a page core does not own would be // has no access — and putting an error box on a page core does not own would be
@@ -40,7 +50,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
const [team, setTeam] = useState(null) const [team, setTeam] = useState(null)
const [state, setState] = useState({ loading: true, forum: null }) const [state, setState] = useState({ loading: true, forum: null })
const [thread, setThread] = useState(null) const [thread, setThread] = useState(null)
const [composing, setComposing] = useState(false) const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null
const openThreadId = params.get('thread') const openThreadId = params.get('thread')
const imageMode = settings?.teams_forum_images || 'disabled' const imageMode = settings?.teams_forum_images || 'disabled'
@@ -54,6 +64,14 @@ export default function TeamForumPanel({ externalId, moduleId }) {
} }
}, []) }, [])
const loadThread = useCallback(async (slug, id) => {
try {
setThread(await api.teamForumThread(slug, id))
} catch {
setThread(null)
}
}, [])
useEffect(() => { useEffect(() => {
let active = true let active = true
// An anonymous visitor has no forum by definition — every route is behind // An anonymous visitor has no forum by definition — every route is behind
@@ -100,9 +118,12 @@ export default function TeamForumPanel({ externalId, moduleId }) {
if (openThreadId && thread) { if (openThreadId && thread) {
return ( return (
<ThreadView <ThreadView
slug={team.slug}
thread={thread} thread={thread}
canModerate={forum.canModerate} canModerate={forum.canModerate}
imageMode={imageMode}
onBack={() => openThread(null)} onBack={() => openThread(null)}
onChanged={() => loadThread(team.slug, thread.id)}
onModerate={async (action) => { onModerate={async (action) => {
await api.teamForumModerate(team.slug, thread.id, { action }) await api.teamForumModerate(team.slug, thread.id, { action })
await loadThreads(team.slug) await loadThreads(team.slug)
@@ -116,22 +137,38 @@ export default function TeamForumPanel({ externalId, moduleId }) {
<section style={{ marginTop: 26 }}> <section style={{ marginTop: 26 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}> <header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}> <h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
Announcements Forum
</h2> </h2>
{forum.canPost && !composing && ( {!composing && (
<button type="button" className="pill" onClick={() => setComposing(true)}> <div style={{ display: 'flex', gap: 8 }}>
{/*
Two buttons, because phase 5 split one capability in two. `canPost`
means "may open a discussion" and every participant may — including a
granted guest with no game character, which is path 3 doing its job.
`canAnnounce` is the leader-only half.
*/}
{forum.canPost && (
<button type="button" className="pill" onClick={() => setComposing('discussion')}>
Start a discussion
</button>
)}
{forum.canAnnounce && (
<button type="button" className="pill" onClick={() => setComposing('announcement')}>
Post an announcement Post an announcement
</button> </button>
)} )}
</div>
)}
</header> </header>
{composing && ( {composing && (
<Composer <Composer
slug={team.slug} slug={team.slug}
type={composing}
imageMode={imageMode} imageMode={imageMode}
onCancel={() => setComposing(false)} onCancel={() => setComposing(null)}
onPosted={async () => { onPosted={async () => {
setComposing(false) setComposing(null)
await loadThreads(team.slug) await loadThreads(team.slug)
}} }}
/> />
@@ -139,7 +176,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
{forum.threads.length === 0 && !composing && ( {forum.threads.length === 0 && !composing && (
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}> <p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
Nothing has been announced here yet. Nothing has been posted here yet.
</p> </p>
)} )}
@@ -158,10 +195,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
}} }}
> >
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>} {t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
{t.locked && <span className="dim" style={{ marginRight: 6 }} title="Locked">🔒</span>}
<strong>{t.title}</strong> <strong>{t.title}</strong>
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}> <span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
{t.author} {threadSummary(t)}
{t.status === 'hidden' && ' · hidden'}
</span> </span>
</button> </button>
</li> </li>
@@ -272,22 +309,167 @@ function GuestManager({ slug }) {
) )
} }
function ThreadView({ thread, canModerate, onBack, onModerate }) { function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, 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
// the window from created_at when it does.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 30_000)
return () => clearInterval(id)
}, [])
const [replying, setReplying] = useState(false)
return ( return (
<section style={{ marginTop: 26 }}> <section style={{ marginTop: 26 }}>
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}> <button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
All announcements All threads
</button> </button>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}> <h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
{thread.title} {thread.title}
</h2> </h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}> <p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.type === 'announcement' ? 'Announcement · ' : ''}
{thread.author} {thread.author}
{thread.authorDeleted && ' (account removed)'} {thread.authorDeleted && ' (account removed)'}
{thread.locked && ' · locked'}
</p> </p>
{thread.posts.map((post) => ( {thread.posts.map((post) => (
<article key={post.id} style={{ marginBottom: 16 }}> <PostView
key={post.id}
slug={slug}
post={post}
canModerate={canModerate}
now={now}
onChanged={onChanged}
/>
))}
{/*
`canReply` is the server's answer to "does this thread take replies right
now", and it folds together the two reasons it might not: an announcement
takes none by TYPE, and a locked thread takes none by STATE. Both are
reported separately above so the reader can see which.
*/}
{thread.canReply && !replying && (
<button type="button" className="pill" onClick={() => setReplying(true)} style={{ marginTop: 4 }}>
Reply
</button>
)}
{thread.canReply && replying && (
<ReplyBox
slug={slug}
threadId={thread.id}
imageMode={imageMode}
onCancel={() => setReplying(false)}
onPosted={async () => {
setReplying(false)
await onChanged()
}}
/>
)}
{!thread.canReply && thread.locked && (
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 10 }}>
This thread is locked. Nobody can reply to it, including staff a moderator who wants the
last word unlocks it first, which leaves a record.
</p>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
<ReportControl
slug={slug}
targetType="team_forum_thread"
targetId={thread.id}
label="Report this thread"
/>
{canModerate && (
<>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.locked ? 'unlock' : 'lock')}>
{thread.locked ? 'Unlock' : 'Lock'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
</>
)}
</div>
</section>
)
}
/**
* One post, with whatever this reader may do to it.
*
* Every capability shown here was decided by the server and is read, not
* computed: `canEdit` and `editableUntil` come stamped on the post, and
* `canModerate` on the thread. The one local judgement is whether an
* already-granted edit window has since elapsed, which can only take an offer
* away.
*/
function PostView({ slug, post, canModerate, now, onChanged }) {
const [editing, setEditing] = useState(false)
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now])
const save = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumEditPost(slug, post.id, { body })
setEditing(false)
await onChanged()
} catch (err) {
setError(err.message || 'Could not save that')
} finally {
setBusy(false)
}
}
const moderate = async (action) => {
setError(null)
try {
await api.teamForumModeratePost(slug, post.id, { action })
await onChanged()
} catch (err) {
setError(err.message || 'Could not do that')
}
}
return (
<article style={{ marginBottom: 16 }}>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
{post.author}
{post.authorDeleted && ' (account removed)'}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
</p>
{editing ? (
<form onSubmit={save} style={{ display: 'grid', gap: 8 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={6}
required
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Save</button>
<button type="button" className="pill" onClick={() => setEditing(false)}>Cancel</button>
</div>
</form>
) : (
<>
{/* {/*
Sanitised on write with the forum's own profile, rendered server-side Sanitised on write with the forum's own profile, rendered server-side
under the operator's image policy, and re-sanitised here — the same under the operator's image policy, and re-sanitised here — the same
@@ -307,25 +489,138 @@ function ThreadView({ thread, canModerate, onBack, onModerate }) {
className="prose" className="prose"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
/> />
</article> </>
))} )}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
{!editing && (
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{stillEditable && (
<button
type="button"
className="pill"
onClick={() => { setBody(stripToText(post.body)); setEditing(true) }}
>
Edit
</button>
)}
{/* Reporting your own post is pointless rather than harmful, but
offering it reads as an invitation to misunderstand the control. */}
{!post.mine && (
<ReportControl
slug={slug}
targetType="team_forum_post"
targetId={post.id}
label="Report"
/>
)}
{canModerate && ( {canModerate && (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}> <>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}> <button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.pinned ? 'Unpin' : 'Pin'} {post.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button> </button>
<button type="button" className="pill" onClick={() => moderate('delete')}>Delete</button>
</>
)}
</div> </div>
)} )}
</section> </article>
) )
} }
function Composer({ slug, imageMode, onCancel, onPosted }) { /**
const [title, setTitle] = useState('') * The report control — the first user-facing report flow this site has ever had.
*
* **It goes to site staff, and it says so.** The gap it closes is that leaders
* moderate their own Team's forum and a Team's leaders are exactly the people who
* will not report their own Team, so telling a member where the report lands is
* not reassurance copy — it is the whole reason the control is worth using in a
* Team whose leadership is the problem.
*
* A report changes nothing about the content, and the confirmation says that too,
* because a member who expects a post to vanish and watches it stay will report
* it again.
*/
function ReportControl({ slug, targetType, targetId, label }) {
const [open, setOpen] = useState(false)
const [reason, setReason] = useState('abuse')
const [detail, setDetail] = useState('')
const [done, setDone] = useState(false)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumReport(slug, { targetType, targetId, reason, detail: detail || undefined })
setDone(true)
setOpen(false)
} catch (err) {
setError(err.message || 'Could not send that')
} finally {
setBusy(false)
}
}
if (done) {
return (
<span className="sans dim" style={{ fontSize: '0.8rem' }}>
Reported to site staff.
</span>
)
}
if (!open) {
return (
<button type="button" className="pill" onClick={() => setOpen(true)}>
{label}
</button>
)
}
return (
<form
onSubmit={submit}
style={{
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
}}
>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
This goes to <strong>site staff</strong>, not to this Team&rsquo;s leaders. Reporting does not
hide or change anything it asks a staffer to look.
</p>
<label className="sans" style={{ fontSize: '0.85rem' }}>
Reason
{' '}
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
{REPORT_REASONS.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
</label>
<textarea
className="textarea"
value={detail}
onChange={(e) => setDetail(e.target.value)}
placeholder="Anything a staffer should know (optional)"
maxLength={500}
rows={3}
/>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
<button type="button" className="pill" onClick={() => setOpen(false)}>Cancel</button>
</div>
</form>
)
}
/** A reply to an open discussion thread. */
function ReplyBox({ slug, threadId, imageMode, onCancel, onPosted }) {
const [body, setBody] = useState('') const [body, setBody] = useState('')
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -335,7 +630,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
setBusy(true) setBusy(true)
setError(null) setError(null)
try { try {
await api.teamForumPost(slug, { type: 'announcement', title, body }) await api.teamForumReply(slug, threadId, { body })
await onPosted() await onPosted()
} catch (err) { } catch (err) {
setError(err.message || 'Could not post that') setError(err.message || 'Could not post that')
@@ -344,18 +639,78 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
} }
} }
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 10 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write a reply. Paste an image URL on its own line to share a picture."
rows={5}
required
/>
{imageMode === 'uploads' && (
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post reply</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
/**
* The upload control, shared by both composers.
*
* The URL goes into the BODY as text, never as an `<img>` tag. The author never
* writes markup here — core decides at render time whether a URL becomes a
* picture, which is what makes the operator's image policy enforceable rather
* than decorative.
*/
function ImageAttacher({ slug, onAttached, onError }) {
const attach = async (event) => { const attach = async (event) => {
const file = event.target.files?.[0] const file = event.target.files?.[0]
if (!file) return if (!file) return
try { try {
const { url } = await api.teamForumUpload(slug, file) const { url } = await api.teamForumUpload(slug, file)
// The URL goes into the BODY as text, not as an <img> tag. The author never onAttached(url)
// writes markup here — core decides at render time whether a URL becomes a
// picture, which is what makes the operator's image policy enforceable
// rather than decorative.
setBody((current) => `${current}${current ? '\n\n' : ''}${url}`)
} catch (err) { } catch (err) {
setError(err.message || 'Could not upload that') onError(err.message || 'Could not upload that')
}
}
return (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
)
}
function Composer({ slug, type, imageMode, onCancel, onPosted }) {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const isAnnouncement = type === 'announcement'
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
// `type` is always sent explicitly. The server defaults an absent one to
// `announcement` so that a phase-4 client keeps meaning what it meant, and
// relying on that default here would make a discussion depend on a
// compatibility shim.
await api.teamForumPost(slug, { type, title, body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
} finally {
setBusy(false)
} }
} }
@@ -373,18 +728,25 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
className="textarea" className="textarea"
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
placeholder="Write your announcement. Paste an image URL on its own line to share a picture." placeholder={isAnnouncement
? 'Write your announcement. Paste an image URL on its own line to share a picture.'
: 'Start the discussion. Paste an image URL on its own line to share a picture.'}
rows={6} rows={6}
required required
/> />
{isAnnouncement && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
Announcements cannot be replied to.
</p>
)}
{imageMode === 'uploads' && ( {imageMode === 'uploads' && (
<label className="sans dim" style={{ fontSize: '0.85rem' }}> <ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
)} )}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>} {error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post</button> <button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{isAnnouncement ? 'Post announcement' : 'Start discussion'}
</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button> <button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div> </div>
</form> </form>

View File

@@ -76,6 +76,11 @@ export const NAV = [
items: [ items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
// Member-raised reports (TEAMS.md §5.6). Here rather than under Teams
// because a staffer working a queue should have one place to work — and
// because the queue is deliberately generic, so the next thing that can
// be reported arrives as a row rather than as another nav entry.
{ to: '/admin/moderation/reports', label: 'Reports', icon: IconShield, roles: ['admin', 'moderator'] },
// Moderation rather than System: the screen's daily job is the // Moderation rather than System: the screen's daily job is the
// reserved-name review queue, which is moderator work. The three actions // reserved-name review queue, which is moderator work. The three actions
// that publish a game-written name are gated to admins server-side, so a // that publish a game-written name are gated to admins server-side, so a
@@ -140,6 +145,7 @@ const TITLES = {
'/admin/hero': 'Hero Editor', '/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation', '/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals', '/admin/moderation/appeals': 'Appeals',
'/admin/moderation/reports': 'Reports',
'/admin/settings': 'Site Settings', '/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance', '/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation', '/admin/navigation': 'Navigation',

View File

@@ -0,0 +1,310 @@
import { useCallback, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// The member-raised content-report queue (TEAMS.md §5.6).
//
// **This is the only view of this queue, and that is the design.** The gap §5.6
// exists to close has a specific shape: leaders moderate their own Team's forum,
// and a Team's leaders are exactly the people who will not report their own Team.
// A leader-visible queue would route a complaint about a leader back to that
// leader. Org lead, 2026-08-18: reports are **site administration only**. If a
// leader-facing view is ever wanted it is a design decision, not a component.
//
// It sits beside Appeals rather than under Teams because a staffer working a
// queue should have one place to work — and because `target_type` is deliberately
// open-ended, so the next consumer (a wiki page, a news comment) arrives as a new
// 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.
const STATUS_TABS = [
{ key: 'open_work', label: 'Open work', param: undefined },
{ key: 'open', label: 'Open', param: 'open' },
{ key: 'reviewing', label: 'Reviewing', param: 'reviewing' },
{ key: 'actioned', label: 'Actioned', param: 'actioned' },
{ key: 'dismissed', label: 'Dismissed', param: 'dismissed' },
{ key: 'all', label: 'All', param: 'all' },
]
const STATUS_STYLE = {
open: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
reviewing: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
actioned: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
dismissed: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
}
const STATUS_LABEL = {
open: 'Open', reviewing: 'Reviewing', actioned: 'Actioned', dismissed: 'Dismissed',
}
const REASON_LABEL = {
spam: 'Spam',
abuse: 'Abuse',
sexual: 'Sexual',
illegal: 'Illegal',
impersonation: 'Impersonation',
other: 'Other',
}
const bytes = (n) => {
if (!n && n !== 0) return ''
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
/**
* What was reported, rendered from the row the queue already resolved.
*
* Nothing here fetches: §5.6's fourth rule is that a staffer sees uploader, size
* and sniffed type without hunting, and the server attaches all of it in three
* batched reads. A `null` target is a target that has since been hard-deleted,
* and the row still shows — "somebody reported this and by the time we looked it
* 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 TargetCell({ report }) {
const t = report.target
if (!t) {
return (
<span style={{ color: 'var(--muted)' }}>
{report.targetType.replace('team_forum_', '')} #{report.targetId} no longer exists
</span>
)
}
if (t.kind === 'upload') {
return (
<span>
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{t.deleted && ' · removed'}
</span>
</span>
)
}
if (t.kind === 'thread') {
return (
<span>
<strong>{t.title}</strong>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.type} by {t.author || 'unknown'}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
return (
<span>
{t.excerpt || <em className="dim">(no text)</em>}
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.author || 'unknown'} in {t.threadTitle}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
export default function ContentReports() {
const [tab, setTab] = useState('open_work')
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const [handling, setHandling] = useState(null)
const [notice, setNotice] = useState(null)
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
const { loading, error, data } = useAsync(
() => api.admin.contentReports({ status: activeTab.param }),
[tab, tick],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load reports." />
const rows = data?.reports || []
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&rsquo;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.
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
{STATUS_TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="pill"
style={tab === t.key ? activePill : undefined}
>
{t.label}
</button>
))}
</div>
{notice && (
<p
className="sans"
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
>
{notice.text}
</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Reported content</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Reporter</th>
<th className="adm-th">Age</th>
<th className="adm-th">Status</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={7} style={muted}>
No reports match this filter.
</td>
</tr>
)}
{rows.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 340 }}>
<TargetCell report={r} />
</td>
<td className="adm-td">
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
</td>
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
<td className="adm-td dim">{r.reporter}</td>
<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.handledBy && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
{r.handledBy}
{r.handledNote ? `${r.handledNote}` : ''}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
onClick={() => setHandling(r)}
className="btn btn-primary btn-sq"
style={{ padding: '5px 12px', fontSize: '0.82rem' }}
>
Handle
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{handling && (
<HandleModal
report={handling}
onCancel={() => setHandling(null)}
onDone={() => {
setHandling(null)
setNotice({ text: 'Report updated.', tone: 'ok' })
reload()
}}
onError={(message) => setNotice({ text: message, tone: 'error' })}
/>
)}
</section>
)
}
/**
* Record a decision about a report.
*
* The note is optional and worth writing: every transition is audited, dismissals
* included, and the note is what the next staffer to see a repeat report about the
* same content reads to find out why the last one was closed.
*/
function HandleModal({ report, onCancel, onDone, onError }) {
const [status, setStatus] = useState(report.status === 'open' ? 'reviewing' : 'actioned')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
setBusy(true)
try {
await api.admin.handleContentReport(report.id, { status, note: note || undefined })
onDone()
} catch (err) {
onError(err.message || 'Could not update that report.')
setBusy(false)
}
}
return (
<Modal
title={`Report #${report.id}`}
onClose={onCancel}
footer={(
<>
<button className="pill" onClick={onCancel}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
</>
)}
>
<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.
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{['reviewing', 'actioned', 'dismissed', 'open'].map((value) => (
<button
key={value}
onClick={() => setStatus(value)}
className="pill"
style={status === value ? activePill : undefined}
>
{STATUS_LABEL[value]}
</button>
))}
</div>
<label>
<span className="field-label">Note (optional)</span>
<textarea
className="textarea"
placeholder="Why this was actioned or dismissed — the next staffer to see a repeat report reads this."
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
rows={4}
style={{ width: '100%' }}
/>
</label>
</div>
</Modal>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const muted = { color: 'var(--muted)' }

View File

@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx' import { useSite } from '../../../contexts/SiteContext.jsx'
// The operator's two Team-forum controls (TEAMS.md §5.5), and the acknowledgement. // The operator's Team-forum controls (TEAMS.md §5.5, plus phase 5's edit window),
// and the acknowledgement.
// //
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the // Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
// same reason EmailDelivery is its own: one of these settings has a server-side // same reason EmailDelivery is its own: one of these settings has a server-side
@@ -68,6 +69,7 @@ export default function TeamForumSettings() {
const [state, setState] = useState(null) const [state, setState] = useState(null)
const [enabled, setEnabled] = useState(false) const [enabled, setEnabled] = useState(false)
const [mode, setMode] = useState('disabled') const [mode, setMode] = useState('disabled')
const [editWindow, setEditWindow] = useState('15')
const [dialog, setDialog] = useState(null) const [dialog, setDialog] = useState(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -79,6 +81,7 @@ export default function TeamForumSettings() {
setState(s) setState(s)
setEnabled(s.enabled) setEnabled(s.enabled)
setMode(s.imageMode) setMode(s.imageMode)
setEditWindow(String(s.editWindowMinutes ?? 15))
} catch { } catch {
setError('Could not load forum settings.') setError('Could not load forum settings.')
} }
@@ -97,6 +100,7 @@ export default function TeamForumSettings() {
await api.admin.updateSettings({ await api.admin.updateSettings({
teams_forums_enabled: next.enabled ? '1' : '0', teams_forums_enabled: next.enabled ? '1' : '0',
teams_forum_images: next.mode, teams_forum_images: next.mode,
teams_forum_edit_window_minutes: String(next.editWindow),
...(acknowledge ? { acknowledge } : {}), ...(acknowledge ? { acknowledge } : {}),
}) })
setSaved(true) setSaved(true)
@@ -115,14 +119,14 @@ export default function TeamForumSettings() {
function save() { function save() {
setSaved(false) setSaved(false)
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) { if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
setDialog({ enabled, mode }) setDialog({ enabled, mode, editWindow })
return return
} }
if (stale) { if (stale) {
setDialog({ enabled, mode }) setDialog({ enabled, mode, editWindow })
return return
} }
persist({ enabled, mode }) persist({ enabled, mode, editWindow })
} }
return ( return (
@@ -159,6 +163,25 @@ export default function TeamForumSettings() {
</select> </select>
</label> </label>
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Post edit window (minutes)</span>
<input
type="number"
className="input"
min={0}
max={state.editWindowMax ?? 1440}
value={editWindow}
onChange={(e) => { setEditWindow(e.target.value); setSaved(false) }}
style={{ maxWidth: 120 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
How long an author may edit their own post after writing it. Staff are not bound by it and
may edit at any time. Set it to 0 to make posts permanent once written a bound of some
kind is what stops a post being rewritten out from under someone quoting it, or under a
moderator about to act on a report.
</span>
</label>
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}> <div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)} {HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
<ul style={{ margin: '0 0 6px 18px' }}> <ul style={{ margin: '0 0 6px 18px' }}>
@@ -181,7 +204,12 @@ export default function TeamForumSettings() {
{dialog && ( {dialog && (
<UploadsDialog <UploadsDialog
version={state.acknowledgement.version} version={state.acknowledgement.version}
onCancel={() => { setDialog(null); setMode(state.imageMode); setEnabled(state.enabled) }} onCancel={() => {
setDialog(null)
setMode(state.imageMode)
setEnabled(state.enabled)
setEditWindow(String(state.editWindowMinutes ?? 15))
}}
onConfirm={async (version) => { onConfirm={async (version) => {
setDialog(null) setDialog(null)
await persist(dialog, version) await persist(dialog, version)

View File

@@ -152,7 +152,7 @@ function RequestQueue({ rows, role, onDecide, busy }) {
// ── One Team ─────────────────────────────────────────────────────────────── // ── One Team ───────────────────────────────────────────────────────────────
function TeamRow({ team, role, onAct, busy }) { function TeamRow({ team, role, onAct, busy, onLedger }) {
const status = statusOf(team) const status = statusOf(team)
return ( return (
<tr> <tr>
@@ -181,11 +181,84 @@ function TeamRow({ team, role, onAct, busy }) {
Hide Hide
</button> </button>
))} ))}
<button type="button" className="btn" onClick={() => onLedger(team)} style={{ marginLeft: 6 }}>
Forum log
</button>
</td> </td>
</tr> </tr>
) )
} }
/**
* One Team's forum moderation ledger (TEAMS.md §5.3).
*
* The route and the API method have existed since phase 4 and nothing rendered
* them, which made the ledger a table only a DB client could read. The column
* that earns the screen is `actorRole`: it records WHICH authority was exercised,
* so a leader's ordinary housekeeping stays distinguishable from a staff
* intervention after the fact.
*
* **This is deliberately not merged with the site's mod_actions/appeals pair.**
* That one is Discord-sanction-shaped and bot-owned; routing a guild leader
* locking a thread through it would make ordinary housekeeping an appealable
* sanction with a reversal path into the bot. Every STAFF-exercised action here
* additionally writes activity_log, so the site's accountability trail sees it —
* the two are cross-referenced, not merged.
*/
function ForumLedger({ team, onClose }) {
const [rows, setRows] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let active = true
api.admin.teamForumModeration(team.id)
// `{ entries }`, and the rows are the ledger table's own snake_case
// columns — this endpoint serves them unmapped, unlike the Team payloads
// above it. Reading them as they are, rather than accepting three possible
// shapes, is what makes a change to that endpoint fail here instead of
// rendering an empty table.
.then((res) => { if (active) setRows(res.entries) })
.catch((err) => { if (active) setError(err.message || 'Could not load the forum log.') })
return () => { active = false }
}, [team.id])
return (
<section className="panel">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h2>Forum log {team.displayName}</h2>
<button type="button" className="btn" onClick={onClose}>Close</button>
</header>
{error && <ErrorState message={error} />}
{!rows && !error && <Loading />}
{rows && rows.length === 0 && <p className="muted">Nothing has been moderated in this forum.</p>}
{rows && rows.length > 0 && (
<table className="table">
<thead>
<tr>
<th>When</th><th>Action</th><th>Target</th><th>By</th><th>As</th><th>Reason</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="muted">{dateTime(r.created_at)}</td>
<td>{r.action}</td>
<td className="muted">{r.target_type} #{r.target_id}</td>
<td>{r.actor_username || '—'}</td>
<td>
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
</td>
<td className="muted">{r.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
)
}
// ── The screen ───────────────────────────────────────────────────────────── // ── The screen ─────────────────────────────────────────────────────────────
export default function TeamsAdmin() { export default function TeamsAdmin() {
@@ -198,6 +271,7 @@ export default function TeamsAdmin() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [notice, setNotice] = useState('') const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [ledgerTeam, setLedgerTeam] = useState(null)
const load = useCallback(async () => { const load = useCallback(async () => {
setError('') setError('')
@@ -265,6 +339,8 @@ export default function TeamsAdmin() {
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{notice && <p className="notice">{notice}</p>} {notice && <p className="notice">{notice}</p>}
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} /> <SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} /> <ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} /> <RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
@@ -288,7 +364,14 @@ export default function TeamsAdmin() {
</thead> </thead>
<tbody> <tbody>
{data.teams.map((team) => ( {data.teams.map((team) => (
<TeamRow key={team.id} team={team} role={role} onAct={act} busy={busy} /> <TeamRow
key={team.id}
team={team}
role={role}
onAct={act}
busy={busy}
onLedger={setLedgerTeam}
/>
))} ))}
</tbody> </tbody>
</table> </table>

View File

@@ -185,3 +185,70 @@ test('a module id is URL-encoded on the way into the path', async () => {
await api.admin.disableModule('a b/c') await api.admin.disableModule('a b/c')
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable') assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
}) })
// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
//
// The URL shapes matter more here than they look. Replies hang off a THREAD;
// edits and post moderation hang off a POST; and the report route hangs off the
// forum rather than off either, because a report can name a thread, a post or an
// upload and is not moderation of any of them.
test('a reply hangs off its thread and an edit hangs off its post', async () => {
willReply({ body: { ok: true } })
await api.teamForumReply('ossuary', 5, { body: 'hi' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
assert.equal(calls[0].opts.method, 'POST')
calls = []
willReply({ body: { ok: true } })
await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
// PATCH, not POST: an edit replaces part of a post that already exists, and the
// server's route is mounted on the verb.
assert.equal(calls[0].opts.method, 'PATCH')
})
test('post moderation is a different route from thread moderation', async () => {
// Not the same route with a target kind, because the two answer to different
// rules — `pin` and `lock` mean nothing to a post at all.
willReply({ body: { ok: true } })
await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
calls = []
willReply({ body: { ok: true } })
await api.teamForumModerate('ossuary', 5, { action: 'pin' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
})
test('a report goes to the forum, and its queue is under admin moderation', async () => {
willReply({ body: { ok: true } })
await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
assert.deepEqual(JSON.parse(calls[0].opts.body), {
targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
})
// Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
// should have one place to work, and there is deliberately no leader-facing
// counterpart to this call anywhere in the client (TEAMS.md §5.6).
calls = []
willReply({ body: { reports: [] } })
await api.admin.contentReports({ status: 'open' })
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
})
test('the report queue defaults to the open work rather than to everything', async () => {
willReply({ body: { reports: [] } })
await api.admin.contentReports()
// No query string at all — the server's default is open + reviewing, and a
// client that pinned `status=all` here would put the archive in front of a
// staffer every time they opened the screen.
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
})
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' })
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
})

View File

@@ -0,0 +1,120 @@
// What the Team forum's client half decides for itself (client/src/lib/teamForum.js).
//
// The point of this file is how LITTLE that is. Who may post, who may moderate,
// whether an image renders and whether a post may be edited are all server
// answers the panel reads. What is tested here is the three places the client
// turns those answers into what a reader sees — and one property that is easy to
// break by accident: the edit offer can only ever be withdrawn here, never
// granted.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../src/lib/teamForum.js'
const NOW = new Date('2026-08-18T12:00:00Z').getTime()
const inMinutes = (n) => new Date(NOW + n * 60_000).toISOString()
// ── the edit offer ─────────────────────────────────────────────────────────
test('the client can withdraw an edit offer and can never create one', () => {
// The server said no. Nothing about a deadline changes that — a future
// `editableUntil` on a post the server refused must not become an offer, or
// the client would be granting a permission.
assert.equal(editOfferOpen({ canEdit: false, editableUntil: inMinutes(10) }, NOW), false)
assert.equal(editOfferOpen({ canEdit: false, editableUntil: null }, NOW), false)
})
test('a deadline that has passed while the page sat open withdraws the offer', () => {
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW), true)
// Same post, fifteen minutes of the reader staring at it later.
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW + 15 * 60_000), false)
})
test('no deadline means no deadline, not no permission', () => {
// Staff are not time-bounded, and `editableUntil: null` is how the server says
// so. Reading it as "expired" would take the edit control away from exactly the
// people whose authority does not expire.
assert.equal(editOfferOpen({ canEdit: true, editableUntil: null }, NOW), true)
})
test('an unparseable deadline closes the offer rather than opening it', () => {
assert.equal(editOfferOpen({ canEdit: true, editableUntil: 'not a date' }, NOW), false)
assert.equal(editOfferOpen(null, NOW), false)
assert.equal(editOfferOpen(undefined, NOW), false)
})
// ── round-tripping a body back into the composer ───────────────────────────
test('the image core generated is stripped, and the URL that made it survives', () => {
// §5.5.3: the author wrote a URL, core emitted the <img> at read time. Handing
// the <img> back would let an author edit markup they never wrote — and the
// URL is what re-renders it, so nothing is lost by removing it.
const rendered = '<p><a href="https://x/a.png" rel="noopener noreferrer">https://x/a.png</a>'
+ '<img src="https://x/a.png" class="forum-embed" referrerpolicy="no-referrer" /></p>'
const text = stripToText(rendered)
assert.ok(!text.includes('<img'))
assert.ok(text.includes('https://x/a.png'))
})
test('paragraphs become blank lines and breaks become newlines', () => {
assert.equal(stripToText('<p>One</p><p>Two</p>'), 'One\n\nTwo')
assert.equal(stripToText('<p>One<br>Two</p>'), 'One\nTwo')
// A paragraph carrying attributes is still a paragraph.
assert.equal(stripToText('<p>One</p>\n<p class="x">Two</p>'), 'One\n\nTwo')
})
test('entities decode to what the author typed, and only once', () => {
assert.equal(stripToText('<p>Tom &amp; Jerry</p>'), 'Tom & Jerry')
assert.equal(stripToText('<p>&quot;quoted&quot;</p>'), '"quoted"')
// The one that bites: an author who typed a literal "<script>" has it stored
// escaped. Decoding entities BEFORE stripping tags would turn it into a real
// tag that the strip pass then deletes — silently losing text the author wrote
// and which was never dangerous.
assert.equal(stripToText('<p>&lt;script&gt;</p>'), '<script>')
// And decoding &amp; first would turn "&amp;lt;" into "<" in two steps.
assert.equal(stripToText('<p>&amp;lt;</p>'), '&lt;')
})
test('an empty or absent body is an empty string, never a crash', () => {
assert.equal(stripToText(''), '')
assert.equal(stripToText(null), '')
assert.equal(stripToText(undefined), '')
assert.equal(stripToText('<p></p>'), '')
})
// ── the thread list line ───────────────────────────────────────────────────
test('a discussion counts REPLIES, which is one fewer than its posts', () => {
// postCount includes the opening post. Showing it raw would tell a reader a
// brand-new thread already has one reply.
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 1 }), 'ada')
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 2 }), 'ada · 1 reply')
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 4 }), 'ada · 3 replies')
})
test('an announcement says so and never counts replies, because it takes none', () => {
const line = threadSummary({ type: 'announcement', author: 'aldric', postCount: 1 })
assert.equal(line, 'Announcement · aldric')
assert.ok(!line.includes('repl'))
})
test('hidden is said out loud — it is only shown to whoever can unhide it', () => {
assert.equal(
threadSummary({ type: 'discussion', author: 'ada', postCount: 1, status: 'hidden' }),
'ada · hidden',
)
})
// ── the report control ─────────────────────────────────────────────────────
test('every reason the server accepts is offered, and no others', () => {
// The server validates against its own list; a client offering a reason the
// server rejects produces a 400 the reporter cannot act on, and one MISSING a
// reason quietly funnels those reports into "other".
assert.deepEqual(
REPORT_REASONS.map(([value]) => value).sort(),
['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'],
)
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
})

View File

@@ -141,6 +141,14 @@ async function forumSettingsState(req, res) {
return res.json({ return res.json({
enabled: await forumSettings.forumsEnabled(), enabled: await forumSettings.forumsEnabled(),
imageMode: await forumSettings.imageMode(), imageMode: await forumSettings.imageMode(),
// Served here rather than published as a public setting: the client that
// needs the NUMBER is the settings screen, and the client that needs the
// DECISION already gets it per post as `canEdit`/`editableUntil`. Publishing
// the window would invite a client to compute the permission itself, which
// is the one thing a time-bounded permission must not let the bounded party
// do.
editWindowMinutes: await forumSettings.editWindowMinutes(),
editWindowMax: forumSettings.EDIT_WINDOW_MAX,
acknowledgement: await forumSettings.ackState(), acknowledgement: await forumSettings.ackState(),
}) })
} catch (err) { } catch (err) {