Files
website/client/src/routes/admin/views/TeamForumSettings.jsx
wtclaude 3f7e61af1c feat(teams): the phase 5 surface — discussion, replies, reports, and two admin screens
241 client tests pass (224 before).

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 13:08:59 -05:00

277 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// 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
// PRECONDITION and a confirmation flow, and a control with a precondition inside a
// generic list of key/value inputs is one whose behaviour nobody reading that list
// would predict.
//
// **The checkbox below is not the gate.** The server rejects `teams_forum_images =
// 'uploads'` with 400 unless the same request carries the acknowledgement version,
// and it does so whether or not this dialog was ever rendered. What is here is how
// the gate is PRESENTED — the wording an operator agrees to, and the recording of
// which version they agreed to.
// §5.5.5(a). Rendered beneath the selector at ALL times, in every mode: it
// explains what the setting is, which is a different job from the confirmation.
const HELP_TEXT = [
'Image uploads are disabled by default.',
'Enabling uploads allows users to store files on infrastructure that you control.',
'By enabling this feature, you acknowledge that you are responsible for:',
]
const HELP_BULLETS = [
'Moderating uploaded content',
'Managing storage and backups',
'Complying with applicable laws and regulations',
'Establishing policies for your community',
]
const HELP_TAIL = [
'Runic Gateway does not provide hosted storage or content moderation services. All uploaded content'
+ ' is stored on your own infrastructure.',
// Addition 1 — the reassuring counterpart, and the reason the attribution table
// in §5.5.4 exists at all.
'Uploads are attributed to the account that made them, and your staff can remove them at any time.',
// Addition 3 — the blast radius. "Users" is doing a lot of work: forum access is
// not the same as game membership, so this genuinely surprises.
'Anyone with access to a team forum can upload, including members granted access manually who have'
+ ' no linked game account.',
]
// §5.5.2's non-blocking advisory for `remote`. Not an acknowledgement — nothing is
// stored in that mode — but the operator's server is still doing the displaying.
const REMOTE_ADVISORY = 'Images hosted elsewhere are loaded by each visitors browser directly from the'
+ ' site hosting them. That site can see your visitors IP addresses, and you do not control whether'
+ ' the image changes or disappears.'
// §5.5.5(b). Shown only when changing the mode TO uploads.
const DIALOG_CHECKS = [
'I understand that uploaded files will be stored on infrastructure that I control.',
'I understand that I am responsible for community moderation policies on this installation.',
]
// Addition 2 — the expectation gap most likely to bite. An operator who turns
// uploads off because of a problem will assume the problem goes with it.
const DIALOG_TAIL = 'Disabling uploads later stops new files being accepted. It does not delete files'
+ ' already uploaded — remove those from the forum moderation tools.'
const MODES = [
{ value: 'disabled', label: 'Disabled — image URLs stay plain links' },
{ value: 'remote', label: 'Remote — images hosted elsewhere are shown' },
{ value: 'uploads', label: 'Uploads — members may upload images to this server' },
]
export default function TeamForumSettings() {
const { refresh: refreshSite } = useSite()
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('')
const [saved, setSaved] = useState(false)
const load = async () => {
try {
const s = await api.admin.teamForumSettings()
setState(s)
setEnabled(s.enabled)
setMode(s.imageMode)
setEditWindow(String(s.editWindowMinutes ?? 15))
} catch {
setError('Could not load forum settings.')
}
}
useEffect(() => { load() }, [])
if (!state) return null
const stale = state.acknowledgement?.stale
async function persist(next, acknowledge) {
setBusy(true)
setError('')
try {
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)
await load()
await refreshSite()
} catch (err) {
setError(err.message || 'Could not save forum settings.')
} finally {
setBusy(false)
}
}
// Moving TO uploads asks first; every other change saves directly. A stale
// acknowledgement also routes through the dialog, because re-acknowledging is
// the only thing that unfreezes these settings.
function save() {
setSaved(false)
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
setDialog({ enabled, mode, editWindow })
return
}
if (stale) {
setDialog({ enabled, mode, editWindow })
return
}
persist({ enabled, mode, editWindow })
}
return (
<section style={{ marginTop: 34, maxWidth: 620 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Team forums</h2>
{stale && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#e0b877', margin: '0 0 12px' }}>
The image-upload notice has changed since it was accepted
{state.acknowledgement.acknowledgedBy ? ` by ${state.acknowledgement.acknowledgedBy}` : ''}.
Uploads keep working, but no forum setting can be saved until it is acknowledged again.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => { setEnabled(e.target.checked); setSaved(false) }}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enable Team forums</span>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Off by default. Switching forums off hides them completely every forum route answers not
found but deletes nothing: threads, posts, access grants and notification preferences all
survive and come back exactly as they were.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Images in forum posts</span>
<select value={mode} onChange={(e) => { setMode(e.target.value); setSaved(false) }} className="select">
{MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</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' }}>
{HELP_BULLETS.map((b) => <li key={b}>{b}</li>)}
</ul>
{HELP_TAIL.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
{mode !== 'disabled' && (
<p style={{ margin: '0 0 6px', color: '#e0b877' }}>{REMOTE_ADVISORY}</p>
)}
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 12, alignItems: 'center' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save forum settings'}
</button>
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
{dialog && (
<UploadsDialog
version={state.acknowledgement.version}
onCancel={() => {
setDialog(null)
setMode(state.imageMode)
setEnabled(state.enabled)
setEditWindow(String(state.editWindowMinutes ?? 15))
}}
onConfirm={async (version) => {
setDialog(null)
await persist(dialog, version)
}}
/>
)}
</section>
)
}
/**
* Two checkboxes, one recorded acknowledgement.
*
* `Enable uploads` stays disabled until both are ticked, but the request carries a
* single version and the stored value is the text VERSION. Recording two booleans
* would add nothing — there is no reachable state where an operator consented to
* one clause and not the other and proceeded anyway — while the version answers
* the question that actually matters later: which text did they agree to?
*/
function UploadsDialog({ version, onCancel, onConfirm }) {
const [checks, setChecks] = useState(DIALOG_CHECKS.map(() => false))
const all = checks.every(Boolean)
return (
<div
role="dialog"
aria-modal="true"
aria-label="Enable image uploads"
style={{
marginTop: 14, padding: 14, border: '1px solid #e0b877', borderRadius: 6,
}}
>
<p className="sans" style={{ margin: '0 0 8px', fontWeight: 600 }}>
Image uploads are currently disabled.
</p>
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.88rem' }}>
Enabling uploads will allow users to store files on your server.
</p>
{DIALOG_CHECKS.map((text, i) => (
<label key={text} className="sans" style={{ display: 'block', fontSize: '0.85rem', marginBottom: 6 }}>
<input
type="checkbox"
checked={checks[i]}
onChange={(e) => setChecks((c) => c.map((v, j) => (j === i ? e.target.checked : v)))}
style={{ marginRight: 8 }}
/>
{text}
</label>
))}
<p className="sans dim" style={{ margin: '10px 0', fontSize: '0.8rem' }}>{DIALOG_TAIL}</p>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={!all}
onClick={() => onConfirm(version)}
>
Enable uploads
</button>
</div>
</div>
)
}