feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -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() {
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
<Route path="reports" element={<ContentReports />} />
|
||||
</Route>
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
|
||||
@@ -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)
|
||||
|
||||
85
client/src/lib/teamForum.js
Normal file
85
client/src/lib/teamForum.js
Normal 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
|
||||
// "<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 "&lt;" 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(' · ')
|
||||
}
|
||||
@@ -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 (
|
||||
<ThreadView
|
||||
slug={team.slug}
|
||||
thread={thread}
|
||||
canModerate={forum.canModerate}
|
||||
imageMode={imageMode}
|
||||
onBack={() => 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 }) {
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
|
||||
Announcements
|
||||
Forum
|
||||
</h2>
|
||||
{forum.canPost && !composing && (
|
||||
<button type="button" className="pill" onClick={() => setComposing(true)}>
|
||||
Post an announcement
|
||||
</button>
|
||||
{!composing && (
|
||||
<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
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{composing && (
|
||||
<Composer
|
||||
slug={team.slug}
|
||||
type={composing}
|
||||
imageMode={imageMode}
|
||||
onCancel={() => setComposing(false)}
|
||||
onCancel={() => setComposing(null)}
|
||||
onPosted={async () => {
|
||||
setComposing(false)
|
||||
setComposing(null)
|
||||
await loadThreads(team.slug)
|
||||
}}
|
||||
/>
|
||||
@@ -139,7 +176,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
||||
|
||||
{forum.threads.length === 0 && !composing && (
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
|
||||
Nothing has been announced here yet.
|
||||
Nothing has been posted here yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -158,10 +195,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
|
||||
{t.author}
|
||||
{t.status === 'hidden' && ' · hidden'}
|
||||
{threadSummary(t)}
|
||||
</span>
|
||||
</button>
|
||||
</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 (
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
|
||||
← All announcements
|
||||
← All threads
|
||||
</button>
|
||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
|
||||
{thread.title}
|
||||
</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
|
||||
{thread.type === 'announcement' ? 'Announcement · ' : ''}
|
||||
{thread.author}
|
||||
{thread.authorDeleted && ' (account removed)'}
|
||||
{thread.locked && ' · locked'}
|
||||
</p>
|
||||
|
||||
{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
|
||||
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'] }) }}
|
||||
/>
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canModerate && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
|
||||
{thread.pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
|
||||
</button>
|
||||
{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 && (
|
||||
<>
|
||||
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||
{post.status === 'hidden' ? 'Unhide' : 'Hide'}
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => moderate('delete')}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</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’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 [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 (
|
||||
<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 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 <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.
|
||||
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 (
|
||||
<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"
|
||||
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 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
|
||||
Announcements cannot be replied to.
|
||||
</p>
|
||||
)}
|
||||
{imageMode === 'uploads' && (
|
||||
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
Attach an image: <input type="file" accept="image/*" onChange={attach} />
|
||||
</label>
|
||||
<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</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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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',
|
||||
|
||||
310
client/src/routes/admin/views/ContentReports.jsx
Normal file
310
client/src/routes/admin/views/ContentReports.jsx
Normal 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’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)' }
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
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
|
||||
// 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 [enabled, setEnabled] = useState(false)
|
||||
const [mode, setMode] = useState('disabled')
|
||||
const [editWindow, setEditWindow] = useState('15')
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -79,6 +81,7 @@ export default function TeamForumSettings() {
|
||||
setState(s)
|
||||
setEnabled(s.enabled)
|
||||
setMode(s.imageMode)
|
||||
setEditWindow(String(s.editWindowMinutes ?? 15))
|
||||
} catch {
|
||||
setError('Could not load forum settings.')
|
||||
}
|
||||
@@ -97,6 +100,7 @@ export default function TeamForumSettings() {
|
||||
await api.admin.updateSettings({
|
||||
teams_forums_enabled: next.enabled ? '1' : '0',
|
||||
teams_forum_images: next.mode,
|
||||
teams_forum_edit_window_minutes: String(next.editWindow),
|
||||
...(acknowledge ? { acknowledge } : {}),
|
||||
})
|
||||
setSaved(true)
|
||||
@@ -115,14 +119,14 @@ export default function TeamForumSettings() {
|
||||
function save() {
|
||||
setSaved(false)
|
||||
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
|
||||
setDialog({ enabled, mode })
|
||||
setDialog({ enabled, mode, editWindow })
|
||||
return
|
||||
}
|
||||
if (stale) {
|
||||
setDialog({ enabled, mode })
|
||||
setDialog({ enabled, mode, editWindow })
|
||||
return
|
||||
}
|
||||
persist({ enabled, mode })
|
||||
persist({ enabled, mode, editWindow })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -159,6 +163,25 @@ export default function TeamForumSettings() {
|
||||
</select>
|
||||
</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 }}>
|
||||
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
||||
<ul style={{ margin: '0 0 6px 18px' }}>
|
||||
@@ -181,7 +204,12 @@ export default function TeamForumSettings() {
|
||||
{dialog && (
|
||||
<UploadsDialog
|
||||
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) => {
|
||||
setDialog(null)
|
||||
await persist(dialog, version)
|
||||
|
||||
@@ -152,7 +152,7 @@ function RequestQueue({ rows, role, onDecide, busy }) {
|
||||
|
||||
// ── One Team ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TeamRow({ team, role, onAct, busy }) {
|
||||
function TeamRow({ team, role, onAct, busy, onLedger }) {
|
||||
const status = statusOf(team)
|
||||
return (
|
||||
<tr>
|
||||
@@ -181,11 +181,84 @@ function TeamRow({ team, role, onAct, busy }) {
|
||||
Hide
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="btn" onClick={() => onLedger(team)} style={{ marginLeft: 6 }}>
|
||||
Forum log
|
||||
</button>
|
||||
</td>
|
||||
</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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TeamsAdmin() {
|
||||
@@ -198,6 +271,7 @@ export default function TeamsAdmin() {
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [ledgerTeam, setLedgerTeam] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
@@ -265,6 +339,8 @@ export default function TeamsAdmin() {
|
||||
{error && <ErrorState message={error} />}
|
||||
{notice && <p className="notice">{notice}</p>}
|
||||
|
||||
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
|
||||
|
||||
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
|
||||
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
||||
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
||||
@@ -288,7 +364,14 @@ export default function TeamsAdmin() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
</table>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
120
client/test/teamForum.test.js
Normal file
120
client/test/teamForum.test.js
Normal 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 & Jerry</p>'), 'Tom & Jerry')
|
||||
assert.equal(stripToText('<p>"quoted"</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><script></p>'), '<script>')
|
||||
// And decoding & first would turn "&lt;" into "<" in two steps.
|
||||
assert.equal(stripToText('<p>&lt;</p>'), '<')
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
@@ -141,6 +141,14 @@ async function forumSettingsState(req, res) {
|
||||
return res.json({
|
||||
enabled: await forumSettings.forumsEnabled(),
|
||||
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(),
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user