diff --git a/client/src/App.jsx b/client/src/App.jsx
index 1750361..a63c557 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -47,6 +47,7 @@ import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
import Appeals from './routes/admin/views/Appeals.jsx'
+import ContentReports from './routes/admin/views/ContentReports.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
@@ -163,6 +164,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 447803d..a8ae529 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -162,6 +162,21 @@ export const api = {
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
teamForumModerate: (slug, id, 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) => {
const fd = new FormData()
fd.append('image', file)
@@ -318,6 +333,18 @@ export const api = {
// ----- moderation dashboard (admin + moderator) -----
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 = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
diff --git a/client/src/lib/teamForum.js b/client/src/lib/teamForum.js
new file mode 100644
index 0000000..b52ae3a
--- /dev/null
+++ b/client/src/lib/teamForum.js
@@ -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
+ * `` 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(/]*>/gi, '')
+ .replace(/<\/p>\s*
]*>/gi, '\n\n')
+ .replace(/ /gi, '\n')
+ .replace(/<[^>]*>/g, '')
+ // Entities last: unescaping before tag-stripping would let an escaped
+ // "<script>" become a real tag the next pass then removes, which is a
+ // different string from the one the author wrote.
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, ' ')
+ // `&` last of all, or "<" would decode two steps into "<".
+ .replace(/&/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(' · ')
+}
diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx
index a823bdb..5b2ea9f 100644
--- a/client/src/modules/TeamForumPanel.jsx
+++ b/client/src/modules/TeamForumPanel.jsx
@@ -1,9 +1,10 @@
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import DOMPurify from 'dompurify'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.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
// (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
// 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
// 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
@@ -40,7 +50,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
const [team, setTeam] = useState(null)
const [state, setState] = useState({ loading: true, forum: 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 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(() => {
let active = true
// 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) {
return (
openThread(null)}
+ onChanged={() => loadThread(team.slug, thread.id)}
onModerate={async (action) => {
await api.teamForumModerate(team.slug, thread.id, { action })
await loadThreads(team.slug)
@@ -116,22 +137,38 @@ export default function TeamForumPanel({ externalId, moduleId }) {
+ {/*
+ 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 && (
+
+ )}
+ {forum.canAnnounce && (
+
+ )}
+
{thread.posts.map((post) => (
-
+
+ ))}
+
+ {/*
+ `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 && (
+
+ )}
+ {thread.canReply && replying && (
+ setReplying(false)}
+ onPosted={async () => {
+ setReplying(false)
+ await onChanged()
+ }}
+ />
+ )}
+ {!thread.canReply && thread.locked && (
+
+ 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.
+
+ )}
+
+
+
+ {canModerate && (
+ <>
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+/**
+ * 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 (
+
+
+
+ {editing ? (
+
+ ) : (
+ <>
{/*
Sanitised on write with the forum's own profile, rendered server-side
under the operator's image policy, and re-sanitised here — the same
@@ -307,25 +489,138 @@ function ThreadView({ thread, canModerate, onBack, onModerate }) {
className="prose"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
/>
-
- ))}
+ >
+ )}
- {canModerate && (
-
-
-
+ {error &&
{error}
}
+
+ {!editing && (
+
+ {stillEditable && (
+
+ )}
+ {/* Reporting your own post is pointless rather than harmful, but
+ offering it reads as an invitation to misunderstand the control. */}
+ {!post.mine && (
+
+ )}
+ {canModerate && (
+ <>
+
+
+ >
+ )}
)}
-
+
)
}
-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 (
+
+ Reported to site staff.
+
+ )
+ }
+
+ if (!open) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+/** A reply to an open discussion thread. */
+function ReplyBox({ slug, threadId, imageMode, onCancel, onPosted }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
@@ -335,7 +630,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
setBusy(true)
setError(null)
try {
- await api.teamForumPost(slug, { type: 'announcement', title, body })
+ await api.teamForumReply(slug, threadId, { body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
@@ -344,18 +639,78 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
}
}
+ return (
+
+ )
+}
+
+/**
+ * The upload control, shared by both composers.
+ *
+ * The URL goes into the BODY as text, never as an `` 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 file = event.target.files?.[0]
if (!file) return
try {
const { url } = await api.teamForumUpload(slug, file)
- // The URL goes into the BODY as text, not as an 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.
- setBody((current) => `${current}${current ? '\n\n' : ''}${url}`)
+ onAttached(url)
} catch (err) {
- setError(err.message || 'Could not upload that')
+ onError(err.message || 'Could not upload that')
+ }
+ }
+
+ return (
+
+ )
+}
+
+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"
value={body}
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}
required
/>
+ {isAnnouncement && (
+
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 7c3313d..db4def1 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -76,6 +76,11 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', 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
// 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
@@ -140,6 +145,7 @@ const TITLES = {
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
+ '/admin/moderation/reports': 'Reports',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
diff --git a/client/src/routes/admin/views/ContentReports.jsx b/client/src/routes/admin/views/ContentReports.jsx
new file mode 100644
index 0000000..9f780a6
--- /dev/null
+++ b/client/src/routes/admin/views/ContentReports.jsx
@@ -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 (
+
+ {report.targetType.replace('team_forum_', '')} #{report.targetId} — no longer exists
+
+ )
+ }
+ if (t.kind === 'upload') {
+ return (
+
+ {t.filename}
+
+ {t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
+ {t.deleted && ' · removed'}
+
+
+ )
+ }
+ if (t.kind === 'thread') {
+ return (
+
+ {t.title}
+
+ {t.type} by {t.author || 'unknown'}
+ {t.status !== 'visible' && ` · ${t.status}`}
+
+
+ )
+ }
+ return (
+
+ {t.excerpt || (no text)}
+
+ {t.author || 'unknown'} in “{t.threadTitle}”
+ {t.status !== 'visible' && ` · ${t.status}`}
+
+
+ )
+}
+
+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
+ if (error) return
+
+ const rows = data?.reports || []
+
+ return (
+
+
+ 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.
+ {typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
+
+
+ {handling && (
+ setHandling(null)}
+ onDone={() => {
+ setHandling(null)
+ setNotice({ text: 'Report updated.', tone: 'ok' })
+ reload()
+ }}
+ onError={(message) => setNotice({ text: message, tone: 'error' })}
+ />
+ )}
+
+ )
+}
+
+/**
+ * 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 (
+
+
+
+ >
+ )}
+ >
+
+
+ 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.
+
)
}
+/**
+ * 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 (
+
+
+
}
+ {ledgerTeam && setLedgerTeam(null)} />}
+
@@ -288,7 +364,14 @@ export default function TeamsAdmin() {
{data.teams.map((team) => (
-
+
))}
diff --git a/client/test/apiClient.test.js b/client/test/apiClient.test.js
index fbf606f..4942133 100644
--- a/client/test/apiClient.test.js
+++ b/client/test/apiClient.test.js
@@ -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')
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')
+})
diff --git a/client/test/teamForum.test.js b/client/test/teamForum.test.js
new file mode 100644
index 0000000..6bae191
--- /dev/null
+++ b/client/test/teamForum.test.js
@@ -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 at read time. Handing
+ // the 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 = '