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 Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||||
import Appeals from './routes/admin/views/Appeals.jsx'
|
import Appeals from './routes/admin/views/Appeals.jsx'
|
||||||
|
import ContentReports from './routes/admin/views/ContentReports.jsx'
|
||||||
|
|
||||||
// Player portal
|
// Player portal
|
||||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||||
@@ -163,6 +164,7 @@ export default function App() {
|
|||||||
<Route index element={<Moderation />} />
|
<Route index element={<Moderation />} />
|
||||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||||
<Route path="appeals" element={<Appeals />} />
|
<Route path="appeals" element={<Appeals />} />
|
||||||
|
<Route path="reports" element={<ContentReports />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
|
|||||||
@@ -162,6 +162,21 @@ export const api = {
|
|||||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
|
||||||
teamForumModerate: (slug, id, body) =>
|
teamForumModerate: (slug, id, body) =>
|
||||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
|
||||||
|
// Phase 5 ("5b"). A reply, an edit and post-level moderation are separate
|
||||||
|
// routes from their thread-level cousins rather than the same route with a
|
||||||
|
// target kind, because they answer to different rules: a reply is refused by a
|
||||||
|
// lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all.
|
||||||
|
teamForumReply: (slug, threadId, body) =>
|
||||||
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }),
|
||||||
|
teamForumEditPost: (slug, postId, body) =>
|
||||||
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }),
|
||||||
|
teamForumModeratePost: (slug, postId, body) =>
|
||||||
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }),
|
||||||
|
// The report goes to SITE STAFF, never to the Team's leaders — the whole point
|
||||||
|
// of it is a path that routes around a Team's own leadership (TEAMS.md §5.6).
|
||||||
|
// There is no leader-facing counterpart to this call and there should not be.
|
||||||
|
teamForumReport: (slug, body) =>
|
||||||
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }),
|
||||||
teamForumUpload: (slug, file) => {
|
teamForumUpload: (slug, file) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('image', file)
|
fd.append('image', file)
|
||||||
@@ -318,6 +333,18 @@ export const api = {
|
|||||||
|
|
||||||
// ----- moderation dashboard (admin + moderator) -----
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
|
// The content-report queue (TEAMS.md §5.6). Under moderation rather than
|
||||||
|
// under Teams because a staffer working a queue should have one place to
|
||||||
|
// work, and a report about a forum post is the same job as a report about
|
||||||
|
// anything else — which is also why `targetType` is open-ended.
|
||||||
|
contentReports: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.status) qs.set('status', opts.status)
|
||||||
|
if (opts.teamId) qs.set('teamId', String(opts.teamId))
|
||||||
|
return req(`/admin/moderation/reports${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
handleContentReport: (id, body) =>
|
||||||
|
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
|
||||||
modRecent: (params = {}) => {
|
modRecent: (params = {}) => {
|
||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
if (params.type) qs.set('type', params.type)
|
if (params.type) qs.set('type', params.type)
|
||||||
|
|||||||
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 { useSearchParams } from 'react-router-dom'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { api } from '../api/client.js'
|
import { api } from '../api/client.js'
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../contexts/SiteContext.jsx'
|
import { useSite } from '../contexts/SiteContext.jsx'
|
||||||
|
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
|
||||||
|
|
||||||
// Core's Team forum, rendered into a second slot a MODULE declares
|
// Core's Team forum, rendered into a second slot a MODULE declares
|
||||||
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
|
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
|
||||||
@@ -28,6 +29,15 @@ import { useSite } from '../contexts/SiteContext.jsx'
|
|||||||
// upload control that would otherwise 404. If the two ever disagree, the server
|
// upload control that would otherwise 404. If the two ever disagree, the server
|
||||||
// is right.
|
// is right.
|
||||||
//
|
//
|
||||||
|
// **Phase 5 added discussion, and with it three capabilities this file must not
|
||||||
|
// invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are
|
||||||
|
// computed on the server and read here. In particular the edit window is a
|
||||||
|
// server decision twice over — the read path stamps `canEdit`/`editableUntil` and
|
||||||
|
// the write re-derives it — because a time-bounded permission must not take its
|
||||||
|
// clock from the party it bounds. What this file does with `editableUntil` is
|
||||||
|
// stop OFFERING an edit whose deadline has passed while the page sat open; it
|
||||||
|
// never grants one.
|
||||||
|
//
|
||||||
// Like the feed, everything here degrades to rendering nothing. A 404 from the
|
// Like the feed, everything here degrades to rendering nothing. A 404 from the
|
||||||
// thread list is the ordinary case — the forum is switched off, or this viewer
|
// thread list is the ordinary case — the forum is switched off, or this viewer
|
||||||
// has no access — and putting an error box on a page core does not own would be
|
// has no access — and putting an error box on a page core does not own would be
|
||||||
@@ -40,7 +50,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
|||||||
const [team, setTeam] = useState(null)
|
const [team, setTeam] = useState(null)
|
||||||
const [state, setState] = useState({ loading: true, forum: null })
|
const [state, setState] = useState({ loading: true, forum: null })
|
||||||
const [thread, setThread] = useState(null)
|
const [thread, setThread] = useState(null)
|
||||||
const [composing, setComposing] = useState(false)
|
const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null
|
||||||
|
|
||||||
const openThreadId = params.get('thread')
|
const openThreadId = params.get('thread')
|
||||||
const imageMode = settings?.teams_forum_images || 'disabled'
|
const imageMode = settings?.teams_forum_images || 'disabled'
|
||||||
@@ -54,6 +64,14 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadThread = useCallback(async (slug, id) => {
|
||||||
|
try {
|
||||||
|
setThread(await api.teamForumThread(slug, id))
|
||||||
|
} catch {
|
||||||
|
setThread(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
// An anonymous visitor has no forum by definition — every route is behind
|
// An anonymous visitor has no forum by definition — every route is behind
|
||||||
@@ -100,9 +118,12 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
|||||||
if (openThreadId && thread) {
|
if (openThreadId && thread) {
|
||||||
return (
|
return (
|
||||||
<ThreadView
|
<ThreadView
|
||||||
|
slug={team.slug}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
canModerate={forum.canModerate}
|
canModerate={forum.canModerate}
|
||||||
|
imageMode={imageMode}
|
||||||
onBack={() => openThread(null)}
|
onBack={() => openThread(null)}
|
||||||
|
onChanged={() => loadThread(team.slug, thread.id)}
|
||||||
onModerate={async (action) => {
|
onModerate={async (action) => {
|
||||||
await api.teamForumModerate(team.slug, thread.id, { action })
|
await api.teamForumModerate(team.slug, thread.id, { action })
|
||||||
await loadThreads(team.slug)
|
await loadThreads(team.slug)
|
||||||
@@ -116,22 +137,38 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
|||||||
<section style={{ marginTop: 26 }}>
|
<section style={{ marginTop: 26 }}>
|
||||||
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
|
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
|
||||||
Announcements
|
Forum
|
||||||
</h2>
|
</h2>
|
||||||
{forum.canPost && !composing && (
|
{!composing && (
|
||||||
<button type="button" className="pill" onClick={() => setComposing(true)}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
Post an announcement
|
{/*
|
||||||
</button>
|
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>
|
</header>
|
||||||
|
|
||||||
{composing && (
|
{composing && (
|
||||||
<Composer
|
<Composer
|
||||||
slug={team.slug}
|
slug={team.slug}
|
||||||
|
type={composing}
|
||||||
imageMode={imageMode}
|
imageMode={imageMode}
|
||||||
onCancel={() => setComposing(false)}
|
onCancel={() => setComposing(null)}
|
||||||
onPosted={async () => {
|
onPosted={async () => {
|
||||||
setComposing(false)
|
setComposing(null)
|
||||||
await loadThreads(team.slug)
|
await loadThreads(team.slug)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -139,7 +176,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
|||||||
|
|
||||||
{forum.threads.length === 0 && !composing && (
|
{forum.threads.length === 0 && !composing && (
|
||||||
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
|
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
|
||||||
Nothing has been announced here yet.
|
Nothing has been posted here yet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -158,10 +195,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
|
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
|
||||||
|
{t.locked && <span className="dim" style={{ marginRight: 6 }} title="Locked">🔒</span>}
|
||||||
<strong>{t.title}</strong>
|
<strong>{t.title}</strong>
|
||||||
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
|
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
|
||||||
{t.author}
|
{threadSummary(t)}
|
||||||
{t.status === 'hidden' && ' · hidden'}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -272,22 +309,167 @@ function GuestManager({ slug }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThreadView({ thread, canModerate, onBack, onModerate }) {
|
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
|
||||||
|
// A clock that ticks, so an edit control whose deadline passed while the page
|
||||||
|
// sat open goes away instead of becoming a button that fails. It only ever
|
||||||
|
// REMOVES an offer — the server decides whether an edit happens, and re-derives
|
||||||
|
// the window from created_at when it does.
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 30_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [replying, setReplying] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ marginTop: 26 }}>
|
<section style={{ marginTop: 26 }}>
|
||||||
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
|
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
|
||||||
← All announcements
|
← All threads
|
||||||
</button>
|
</button>
|
||||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
|
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
|
||||||
{thread.title}
|
{thread.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
|
||||||
|
{thread.type === 'announcement' ? 'Announcement · ' : ''}
|
||||||
{thread.author}
|
{thread.author}
|
||||||
{thread.authorDeleted && ' (account removed)'}
|
{thread.authorDeleted && ' (account removed)'}
|
||||||
|
{thread.locked && ' · locked'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{thread.posts.map((post) => (
|
{thread.posts.map((post) => (
|
||||||
<article key={post.id} style={{ marginBottom: 16 }}>
|
<PostView
|
||||||
|
key={post.id}
|
||||||
|
slug={slug}
|
||||||
|
post={post}
|
||||||
|
canModerate={canModerate}
|
||||||
|
now={now}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
`canReply` is the server's answer to "does this thread take replies right
|
||||||
|
now", and it folds together the two reasons it might not: an announcement
|
||||||
|
takes none by TYPE, and a locked thread takes none by STATE. Both are
|
||||||
|
reported separately above so the reader can see which.
|
||||||
|
*/}
|
||||||
|
{thread.canReply && !replying && (
|
||||||
|
<button type="button" className="pill" onClick={() => setReplying(true)} style={{ marginTop: 4 }}>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{thread.canReply && replying && (
|
||||||
|
<ReplyBox
|
||||||
|
slug={slug}
|
||||||
|
threadId={thread.id}
|
||||||
|
imageMode={imageMode}
|
||||||
|
onCancel={() => setReplying(false)}
|
||||||
|
onPosted={async () => {
|
||||||
|
setReplying(false)
|
||||||
|
await onChanged()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!thread.canReply && thread.locked && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 10 }}>
|
||||||
|
This thread is locked. Nobody can reply to it, including staff — a moderator who wants the
|
||||||
|
last word unlocks it first, which leaves a record.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
|
||||||
|
<ReportControl
|
||||||
|
slug={slug}
|
||||||
|
targetType="team_forum_thread"
|
||||||
|
targetId={thread.id}
|
||||||
|
label="Report this thread"
|
||||||
|
/>
|
||||||
|
{canModerate && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
|
||||||
|
{thread.pinned ? 'Unpin' : 'Pin'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="pill" onClick={() => onModerate(thread.locked ? 'unlock' : 'lock')}>
|
||||||
|
{thread.locked ? 'Unlock' : 'Lock'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||||
|
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One post, with whatever this reader may do to it.
|
||||||
|
*
|
||||||
|
* Every capability shown here was decided by the server and is read, not
|
||||||
|
* computed: `canEdit` and `editableUntil` come stamped on the post, and
|
||||||
|
* `canModerate` on the thread. The one local judgement is whether an
|
||||||
|
* already-granted edit window has since elapsed, which can only take an offer
|
||||||
|
* away.
|
||||||
|
*/
|
||||||
|
function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now])
|
||||||
|
|
||||||
|
const save = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.teamForumEditPost(slug, post.id, { body })
|
||||||
|
setEditing(false)
|
||||||
|
await onChanged()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save that')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderate = async (action) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.teamForumModeratePost(slug, post.id, { action })
|
||||||
|
await onChanged()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not do that')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article style={{ marginBottom: 16 }}>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
|
||||||
|
{post.author}
|
||||||
|
{post.authorDeleted && ' (account removed)'}
|
||||||
|
{post.editedAt && ' · edited'}
|
||||||
|
{post.status === 'hidden' && ' · hidden'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<form onSubmit={save} style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Save</button>
|
||||||
|
<button type="button" className="pill" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/*
|
{/*
|
||||||
Sanitised on write with the forum's own profile, rendered server-side
|
Sanitised on write with the forum's own profile, rendered server-side
|
||||||
under the operator's image policy, and re-sanitised here — the same
|
under the operator's image policy, and re-sanitised here — the same
|
||||||
@@ -307,25 +489,138 @@ function ThreadView({ thread, canModerate, onBack, onModerate }) {
|
|||||||
className="prose"
|
className="prose"
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
|
||||||
/>
|
/>
|
||||||
</article>
|
</>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
{canModerate && (
|
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
|
||||||
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
|
{!editing && (
|
||||||
{thread.pinned ? 'Unpin' : 'Pin'}
|
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||||
</button>
|
{stillEditable && (
|
||||||
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
|
<button
|
||||||
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
|
type="button"
|
||||||
</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>
|
</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 [body, setBody] = useState('')
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@@ -335,7 +630,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.teamForumPost(slug, { type: 'announcement', title, body })
|
await api.teamForumReply(slug, threadId, { body })
|
||||||
await onPosted()
|
await onPosted()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Could not post that')
|
setError(err.message || 'Could not post that')
|
||||||
@@ -344,18 +639,78 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 10 }}>
|
||||||
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Write a reply. Paste an image URL on its own line to share a picture."
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{imageMode === 'uploads' && (
|
||||||
|
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
|
||||||
|
)}
|
||||||
|
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post reply</button>
|
||||||
|
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The upload control, shared by both composers.
|
||||||
|
*
|
||||||
|
* The URL goes into the BODY as text, never as an `<img>` tag. The author never
|
||||||
|
* writes markup here — core decides at render time whether a URL becomes a
|
||||||
|
* picture, which is what makes the operator's image policy enforceable rather
|
||||||
|
* than decorative.
|
||||||
|
*/
|
||||||
|
function ImageAttacher({ slug, onAttached, onError }) {
|
||||||
const attach = async (event) => {
|
const attach = async (event) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
try {
|
try {
|
||||||
const { url } = await api.teamForumUpload(slug, file)
|
const { url } = await api.teamForumUpload(slug, file)
|
||||||
// The URL goes into the BODY as text, not as an <img> tag. The author never
|
onAttached(url)
|
||||||
// writes markup here — core decides at render time whether a URL becomes a
|
|
||||||
// picture, which is what makes the operator's image policy enforceable
|
|
||||||
// rather than decorative.
|
|
||||||
setBody((current) => `${current}${current ? '\n\n' : ''}${url}`)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Could not upload that')
|
onError(err.message || 'Could not upload that')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||||
|
Attach an image: <input type="file" accept="image/*" onChange={attach} />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Composer({ slug, type, imageMode, onCancel, onPosted }) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const isAnnouncement = type === 'announcement'
|
||||||
|
|
||||||
|
const submit = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
// `type` is always sent explicitly. The server defaults an absent one to
|
||||||
|
// `announcement` so that a phase-4 client keeps meaning what it meant, and
|
||||||
|
// relying on that default here would make a discussion depend on a
|
||||||
|
// compatibility shim.
|
||||||
|
await api.teamForumPost(slug, { type, title, body })
|
||||||
|
await onPosted()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not post that')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,18 +728,25 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
|
|||||||
className="textarea"
|
className="textarea"
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
placeholder="Write your announcement. Paste an image URL on its own line to share a picture."
|
placeholder={isAnnouncement
|
||||||
|
? 'Write your announcement. Paste an image URL on its own line to share a picture.'
|
||||||
|
: 'Start the discussion. Paste an image URL on its own line to share a picture.'}
|
||||||
rows={6}
|
rows={6}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{isAnnouncement && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
|
||||||
|
Announcements cannot be replied to.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{imageMode === 'uploads' && (
|
{imageMode === 'uploads' && (
|
||||||
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
|
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
|
||||||
Attach an image: <input type="file" accept="image/*" onChange={attach} />
|
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post</button>
|
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||||
|
{isAnnouncement ? 'Post announcement' : 'Start discussion'}
|
||||||
|
</button>
|
||||||
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export const NAV = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||||
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
|
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||||
|
// Member-raised reports (TEAMS.md §5.6). Here rather than under Teams
|
||||||
|
// because a staffer working a queue should have one place to work — and
|
||||||
|
// because the queue is deliberately generic, so the next thing that can
|
||||||
|
// be reported arrives as a row rather than as another nav entry.
|
||||||
|
{ to: '/admin/moderation/reports', label: 'Reports', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||||
// Moderation rather than System: the screen's daily job is the
|
// Moderation rather than System: the screen's daily job is the
|
||||||
// reserved-name review queue, which is moderator work. The three actions
|
// reserved-name review queue, which is moderator work. The three actions
|
||||||
// that publish a game-written name are gated to admins server-side, so a
|
// that publish a game-written name are gated to admins server-side, so a
|
||||||
@@ -140,6 +145,7 @@ const TITLES = {
|
|||||||
'/admin/hero': 'Hero Editor',
|
'/admin/hero': 'Hero Editor',
|
||||||
'/admin/moderation': 'Moderation',
|
'/admin/moderation': 'Moderation',
|
||||||
'/admin/moderation/appeals': 'Appeals',
|
'/admin/moderation/appeals': 'Appeals',
|
||||||
|
'/admin/moderation/reports': 'Reports',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/appearance': 'Appearance',
|
'/admin/appearance': 'Appearance',
|
||||||
'/admin/navigation': 'Navigation',
|
'/admin/navigation': 'Navigation',
|
||||||
|
|||||||
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 { api } from '../../../api/client.js'
|
||||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
// The operator's two Team-forum controls (TEAMS.md §5.5), and the acknowledgement.
|
// The operator's Team-forum controls (TEAMS.md §5.5, plus phase 5's edit window),
|
||||||
|
// and the acknowledgement.
|
||||||
//
|
//
|
||||||
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
|
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
|
||||||
// same reason EmailDelivery is its own: one of these settings has a server-side
|
// same reason EmailDelivery is its own: one of these settings has a server-side
|
||||||
@@ -68,6 +69,7 @@ export default function TeamForumSettings() {
|
|||||||
const [state, setState] = useState(null)
|
const [state, setState] = useState(null)
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
const [mode, setMode] = useState('disabled')
|
const [mode, setMode] = useState('disabled')
|
||||||
|
const [editWindow, setEditWindow] = useState('15')
|
||||||
const [dialog, setDialog] = useState(null)
|
const [dialog, setDialog] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -79,6 +81,7 @@ export default function TeamForumSettings() {
|
|||||||
setState(s)
|
setState(s)
|
||||||
setEnabled(s.enabled)
|
setEnabled(s.enabled)
|
||||||
setMode(s.imageMode)
|
setMode(s.imageMode)
|
||||||
|
setEditWindow(String(s.editWindowMinutes ?? 15))
|
||||||
} catch {
|
} catch {
|
||||||
setError('Could not load forum settings.')
|
setError('Could not load forum settings.')
|
||||||
}
|
}
|
||||||
@@ -97,6 +100,7 @@ export default function TeamForumSettings() {
|
|||||||
await api.admin.updateSettings({
|
await api.admin.updateSettings({
|
||||||
teams_forums_enabled: next.enabled ? '1' : '0',
|
teams_forums_enabled: next.enabled ? '1' : '0',
|
||||||
teams_forum_images: next.mode,
|
teams_forum_images: next.mode,
|
||||||
|
teams_forum_edit_window_minutes: String(next.editWindow),
|
||||||
...(acknowledge ? { acknowledge } : {}),
|
...(acknowledge ? { acknowledge } : {}),
|
||||||
})
|
})
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
@@ -115,14 +119,14 @@ export default function TeamForumSettings() {
|
|||||||
function save() {
|
function save() {
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
|
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
|
||||||
setDialog({ enabled, mode })
|
setDialog({ enabled, mode, editWindow })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (stale) {
|
if (stale) {
|
||||||
setDialog({ enabled, mode })
|
setDialog({ enabled, mode, editWindow })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
persist({ enabled, mode })
|
persist({ enabled, mode, editWindow })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,6 +163,25 @@ export default function TeamForumSettings() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginTop: 14 }}>
|
||||||
|
<span className="field-label">Post edit window (minutes)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input"
|
||||||
|
min={0}
|
||||||
|
max={state.editWindowMax ?? 1440}
|
||||||
|
value={editWindow}
|
||||||
|
onChange={(e) => { setEditWindow(e.target.value); setSaved(false) }}
|
||||||
|
style={{ maxWidth: 120 }}
|
||||||
|
/>
|
||||||
|
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||||
|
How long an author may edit their own post after writing it. Staff are not bound by it and
|
||||||
|
may edit at any time. Set it to 0 to make posts permanent once written — a bound of some
|
||||||
|
kind is what stops a post being rewritten out from under someone quoting it, or under a
|
||||||
|
moderator about to act on a report.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
|
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
|
||||||
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
||||||
<ul style={{ margin: '0 0 6px 18px' }}>
|
<ul style={{ margin: '0 0 6px 18px' }}>
|
||||||
@@ -181,7 +204,12 @@ export default function TeamForumSettings() {
|
|||||||
{dialog && (
|
{dialog && (
|
||||||
<UploadsDialog
|
<UploadsDialog
|
||||||
version={state.acknowledgement.version}
|
version={state.acknowledgement.version}
|
||||||
onCancel={() => { setDialog(null); setMode(state.imageMode); setEnabled(state.enabled) }}
|
onCancel={() => {
|
||||||
|
setDialog(null)
|
||||||
|
setMode(state.imageMode)
|
||||||
|
setEnabled(state.enabled)
|
||||||
|
setEditWindow(String(state.editWindowMinutes ?? 15))
|
||||||
|
}}
|
||||||
onConfirm={async (version) => {
|
onConfirm={async (version) => {
|
||||||
setDialog(null)
|
setDialog(null)
|
||||||
await persist(dialog, version)
|
await persist(dialog, version)
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ function RequestQueue({ rows, role, onDecide, busy }) {
|
|||||||
|
|
||||||
// ── One Team ───────────────────────────────────────────────────────────────
|
// ── One Team ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TeamRow({ team, role, onAct, busy }) {
|
function TeamRow({ team, role, onAct, busy, onLedger }) {
|
||||||
const status = statusOf(team)
|
const status = statusOf(team)
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -181,11 +181,84 @@ function TeamRow({ team, role, onAct, busy }) {
|
|||||||
Hide
|
Hide
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button type="button" className="btn" onClick={() => onLedger(team)} style={{ marginLeft: 6 }}>
|
||||||
|
Forum log
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Team's forum moderation ledger (TEAMS.md §5.3).
|
||||||
|
*
|
||||||
|
* The route and the API method have existed since phase 4 and nothing rendered
|
||||||
|
* them, which made the ledger a table only a DB client could read. The column
|
||||||
|
* that earns the screen is `actorRole`: it records WHICH authority was exercised,
|
||||||
|
* so a leader's ordinary housekeeping stays distinguishable from a staff
|
||||||
|
* intervention after the fact.
|
||||||
|
*
|
||||||
|
* **This is deliberately not merged with the site's mod_actions/appeals pair.**
|
||||||
|
* That one is Discord-sanction-shaped and bot-owned; routing a guild leader
|
||||||
|
* locking a thread through it would make ordinary housekeeping an appealable
|
||||||
|
* sanction with a reversal path into the bot. Every STAFF-exercised action here
|
||||||
|
* additionally writes activity_log, so the site's accountability trail sees it —
|
||||||
|
* the two are cross-referenced, not merged.
|
||||||
|
*/
|
||||||
|
function ForumLedger({ team, onClose }) {
|
||||||
|
const [rows, setRows] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api.admin.teamForumModeration(team.id)
|
||||||
|
// `{ entries }`, and the rows are the ledger table's own snake_case
|
||||||
|
// columns — this endpoint serves them unmapped, unlike the Team payloads
|
||||||
|
// above it. Reading them as they are, rather than accepting three possible
|
||||||
|
// shapes, is what makes a change to that endpoint fail here instead of
|
||||||
|
// rendering an empty table.
|
||||||
|
.then((res) => { if (active) setRows(res.entries) })
|
||||||
|
.catch((err) => { if (active) setError(err.message || 'Could not load the forum log.') })
|
||||||
|
return () => { active = false }
|
||||||
|
}, [team.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<h2>Forum log — {team.displayName}</h2>
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Close</button>
|
||||||
|
</header>
|
||||||
|
{error && <ErrorState message={error} />}
|
||||||
|
{!rows && !error && <Loading />}
|
||||||
|
{rows && rows.length === 0 && <p className="muted">Nothing has been moderated in this forum.</p>}
|
||||||
|
{rows && rows.length > 0 && (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th><th>Action</th><th>Target</th><th>By</th><th>As</th><th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="muted">{dateTime(r.created_at)}</td>
|
||||||
|
<td>{r.action}</td>
|
||||||
|
<td className="muted">{r.target_type} #{r.target_id}</td>
|
||||||
|
<td>{r.actor_username || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{/* The distinction the whole ledger exists to preserve. */}
|
||||||
|
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
|
||||||
|
</td>
|
||||||
|
<td className="muted">{r.reason || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── The screen ─────────────────────────────────────────────────────────────
|
// ── The screen ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TeamsAdmin() {
|
export default function TeamsAdmin() {
|
||||||
@@ -198,6 +271,7 @@ export default function TeamsAdmin() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [notice, setNotice] = useState('')
|
const [notice, setNotice] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [ledgerTeam, setLedgerTeam] = useState(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError('')
|
setError('')
|
||||||
@@ -265,6 +339,8 @@ export default function TeamsAdmin() {
|
|||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} />}
|
||||||
{notice && <p className="notice">{notice}</p>}
|
{notice && <p className="notice">{notice}</p>}
|
||||||
|
|
||||||
|
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
|
||||||
|
|
||||||
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
|
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
|
||||||
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
||||||
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
||||||
@@ -288,7 +364,14 @@ export default function TeamsAdmin() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.teams.map((team) => (
|
{data.teams.map((team) => (
|
||||||
<TeamRow key={team.id} team={team} role={role} onAct={act} busy={busy} />
|
<TeamRow
|
||||||
|
key={team.id}
|
||||||
|
team={team}
|
||||||
|
role={role}
|
||||||
|
onAct={act}
|
||||||
|
busy={busy}
|
||||||
|
onLedger={setLedgerTeam}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -185,3 +185,70 @@ test('a module id is URL-encoded on the way into the path', async () => {
|
|||||||
await api.admin.disableModule('a b/c')
|
await api.admin.disableModule('a b/c')
|
||||||
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The URL shapes matter more here than they look. Replies hang off a THREAD;
|
||||||
|
// edits and post moderation hang off a POST; and the report route hangs off the
|
||||||
|
// forum rather than off either, because a report can name a thread, a post or an
|
||||||
|
// upload and is not moderation of any of them.
|
||||||
|
|
||||||
|
test('a reply hangs off its thread and an edit hangs off its post', async () => {
|
||||||
|
willReply({ body: { ok: true } })
|
||||||
|
await api.teamForumReply('ossuary', 5, { body: 'hi' })
|
||||||
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
|
||||||
|
assert.equal(calls[0].opts.method, 'POST')
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
willReply({ body: { ok: true } })
|
||||||
|
await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
|
||||||
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
|
||||||
|
// PATCH, not POST: an edit replaces part of a post that already exists, and the
|
||||||
|
// server's route is mounted on the verb.
|
||||||
|
assert.equal(calls[0].opts.method, 'PATCH')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('post moderation is a different route from thread moderation', async () => {
|
||||||
|
// Not the same route with a target kind, because the two answer to different
|
||||||
|
// rules — `pin` and `lock` mean nothing to a post at all.
|
||||||
|
willReply({ body: { ok: true } })
|
||||||
|
await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
|
||||||
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
willReply({ body: { ok: true } })
|
||||||
|
await api.teamForumModerate('ossuary', 5, { action: 'pin' })
|
||||||
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a report goes to the forum, and its queue is under admin moderation', async () => {
|
||||||
|
willReply({ body: { ok: true } })
|
||||||
|
await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
|
||||||
|
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].opts.body), {
|
||||||
|
targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
|
||||||
|
// should have one place to work, and there is deliberately no leader-facing
|
||||||
|
// counterpart to this call anywhere in the client (TEAMS.md §5.6).
|
||||||
|
calls = []
|
||||||
|
willReply({ body: { reports: [] } })
|
||||||
|
await api.admin.contentReports({ status: 'open' })
|
||||||
|
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the report queue defaults to the open work rather than to everything', async () => {
|
||||||
|
willReply({ body: { reports: [] } })
|
||||||
|
await api.admin.contentReports()
|
||||||
|
// No query string at all — the server's default is open + reviewing, and a
|
||||||
|
// client that pinned `status=all` here would put the archive in front of a
|
||||||
|
// staffer every time they opened the screen.
|
||||||
|
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a Team slug is URL-encoded on every forum path', async () => {
|
||||||
|
willReply({ body: { ok: true } })
|
||||||
|
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||||
|
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
||||||
|
})
|
||||||
|
|||||||
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({
|
return res.json({
|
||||||
enabled: await forumSettings.forumsEnabled(),
|
enabled: await forumSettings.forumsEnabled(),
|
||||||
imageMode: await forumSettings.imageMode(),
|
imageMode: await forumSettings.imageMode(),
|
||||||
|
// Served here rather than published as a public setting: the client that
|
||||||
|
// needs the NUMBER is the settings screen, and the client that needs the
|
||||||
|
// DECISION already gets it per post as `canEdit`/`editableUntil`. Publishing
|
||||||
|
// the window would invite a client to compute the permission itself, which
|
||||||
|
// is the one thing a time-bounded permission must not let the bounded party
|
||||||
|
// do.
|
||||||
|
editWindowMinutes: await forumSettings.editWindowMinutes(),
|
||||||
|
editWindowMax: forumSettings.EDIT_WINDOW_MAX,
|
||||||
acknowledgement: await forumSettings.ackState(),
|
acknowledgement: await forumSettings.ackState(),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user