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>
277 lines
11 KiB
JavaScript
277 lines
11 KiB
JavaScript
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 visitor’s 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>
|
||
)
|
||
}
|