feat(teams): the forum panel core fills, and the operator's controls

The forum had nowhere to live. TEAMS.md 3.1 gave it a CORE page, and phase 3
deleted every core Team page — Teams is a contract primitive and core does not own
the word for one. So the forum follows the activity feed: module-uo declares a
second place on its guild page and core fills it.

TWO slots rather than one, because a slot holds one component and the first fill
wins. Stacking the feed and the forum into a single fill would take from the module
the ability to place core's two contributions separately on its own page, which is
the whole point of the module owning it.

The panel navigates by SEARCH PARAM (?thread=12) rather than by route. A thread has
to be linkable and core cannot mount a route for one — the route belongs to the
module's page — so a search param gives a shareable URL under whatever path the
module chose, with the back button intact and no core route anywhere in it. That is
why the fill is one component holding both a list view and a detail view.

Post bodies arrive already rendered by the server under the current image policy,
which is why they are set as HTML here rather than sanitised again: the body was
cleaned on write with the forum's own profile, and any <img> in it was emitted by
core's own renderer with a fixed attribute set. A client-side sanitiser would have
to strip exactly the tag core just decided to add. The published image mode is read
only to decide which composer to draw — never what renders.

The composer puts an uploaded file's URL into the body as TEXT, not as a tag. The
author never writes markup, which is what keeps the operator's policy enforceable.

The admin panel carries both settings, the always-on help text, and the
confirmation dialog with its two checkboxes and one recorded acknowledgement — plus
the three additions the org lead settled: attribution and staff removal, the
warning that disabling later does not delete existing files, and who "users"
actually means. A stale acknowledgement raises a banner and freezes the settings;
it does not turn uploads off.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:24:24 -05:00
parent 4ac353684a
commit cbb7339a3a
5 changed files with 669 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx'
import TeamForumSettings from './TeamForumSettings.jsx'
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
</div>
</div>
<TeamForumSettings />
<EmailDelivery />
</section>
)

View File

@@ -0,0 +1,248 @@
import { useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// The operator's two Team-forum controls (TEAMS.md §5.5), and the acknowledgement.
//
// 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 [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)
} 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,
...(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 })
return
}
if (stale) {
setDialog({ enabled, mode })
return
}
persist({ enabled, mode })
}
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>
<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) }}
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>
)
}