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

@@ -152,6 +152,26 @@ export const api = {
if (opts.offset != null) qs.set('offset', String(opts.offset)) if (opts.offset != null) qs.set('offset', String(opts.offset))
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`) return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
}, },
// The Team FORUM, under /player because a participant may be a plain player and
// a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
// core's: only core resolves whether this viewer is inside the Team, and the
// member/guest split is a security boundary. The module renders the PLACE.
teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
teamForumPost: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
teamForumModerate: (slug, id, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
teamForumUpload: (slug, file) => {
const fd = new FormData()
fd.append('image', file)
return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
},
teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
teamGrantAdd: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
teamGrantRevoke: (slug, userId) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
wikiTags: () => req('/public/wiki/tags'), wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`), wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link // CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -284,6 +304,13 @@ export const api = {
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }), req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
clearTeamLeaderOverride: (id, memberKey) => clearTeamLeaderOverride: (id, memberKey) =>
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }), req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
teamForumSettings: () => req('/admin/teams/forum/settings'),
teamForumUploads: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.deleted) qs.set('deleted', '1')
return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
},
teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
teamReviewQueue: () => req('/admin/teams/review'), teamReviewQueue: () => req('/admin/teams/review'),
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`), teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
decideTeamRequest: (id, status, note) => decideTeamRequest: (id, status, note) =>

View File

@@ -5,6 +5,7 @@ import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js' import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js' import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
import TeamActivityFeed from './modules/TeamActivityFeed.jsx' import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
import TeamForumPanel from './modules/TeamForumPanel.jsx'
import './styles/theme.css' import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates. // Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -75,6 +76,14 @@ declareSlot('player.invite.accepted')
// unfilled slot rendering nothing. // unfilled slot rendering nothing.
fillModuleSlot('uo.guild.detail', TeamActivityFeed) fillModuleSlot('uo.guild.detail', TeamActivityFeed)
// The forum is core's for the same reason and goes in a SECOND place the module
// declares, rather than joining the feed in the first: a slot takes one component
// (first fill wins), and stacking two unrelated panels into one fill would make
// the module unable to place them separately on its own page. It also keeps the
// two independent — a deployment with the forum switched off renders the feed
// exactly as before.
fillModuleSlot('uo.guild.forum', TeamForumPanel)
// Render on DOMContentLoaded rather than immediately, and that is the one line // Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes. // of core's boot the module system changes.
// //

View File

@@ -0,0 +1,382 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
// Core's Team forum, rendered into a second slot a MODULE declares
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
//
// **Why the forum is core's content on a module's page.** Everything that decides
// who may read a thread is core's — the §2.5 resolver, the grants ledger, the
// member/guest distinction — and none of it is a module's to reimplement. But
// core does not own the word for a Team, so it publishes no Team page: the module
// that says "guild" owns the page and declares a place on it, and core fills the
// place. Same direction as the activity feed, same reason.
//
// **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A
// thread needs to be linkable, and core cannot mount a route for it — the route
// belongs to the module's page. `?thread=12` gives a shareable URL that works
// under whatever path the module chose, with no route of core's anywhere in it,
// and the browser's back button behaves. That is the whole reason this component
// holds a list view and a detail view rather than being two components.
//
// **The image mode is published so this can draw the right composer — never to
// decide what renders.** Post bodies arrive already rendered by the server under
// the current policy (§5.5.3); the mode is read here only to show or hide an
// upload control that would otherwise 404. If the two ever disagree, the server
// is right.
//
// Like the feed, everything here degrades to rendering nothing. A 404 from the
// thread list is the ordinary case — the forum is switched off, or this viewer
// has no access — and putting an error box on a page core does not own would be
// core reporting its own absence as a defect on someone else's surface.
export default function TeamForumPanel({ externalId, moduleId }) {
const { user } = useAuth()
const { settings } = useSite()
const [params, setParams] = useSearchParams()
const [team, setTeam] = useState(null)
const [state, setState] = useState({ loading: true, forum: null })
const [thread, setThread] = useState(null)
const [composing, setComposing] = useState(false)
const openThreadId = params.get('thread')
const imageMode = settings?.teams_forum_images || 'disabled'
const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1'
const loadThreads = useCallback(async (slug) => {
try {
setState({ loading: false, forum: await api.teamForumThreads(slug) })
} catch {
setState({ loading: false, forum: null })
}
}, [])
useEffect(() => {
let active = true
// An anonymous visitor has no forum by definition — every route is behind
// requireAuth — so skip the two calls rather than provoking a 401 per page.
if (!externalId || !moduleId || !user || !forumsEnabled) {
setState({ loading: false, forum: null })
return undefined
}
// The module names the Team its own way; core resolves that to a slug. Same
// two-call shape as the activity feed, and for the same reason: a module
// never has to hold core's identifiers.
api.teamByExternalId(moduleId, externalId)
.then(async (found) => {
if (!active) return
setTeam(found)
await loadThreads(found.slug)
})
.catch(() => { if (active) setState({ loading: false, forum: null }) })
return () => { active = false }
}, [externalId, moduleId, user, forumsEnabled, loadThreads])
useEffect(() => {
let active = true
if (!team || !openThreadId) {
setThread(null)
return undefined
}
api.teamForumThread(team.slug, openThreadId)
.then((t) => { if (active) setThread(t) })
.catch(() => { if (active) setThread(null) })
return () => { active = false }
}, [team, openThreadId])
const openThread = (id) => {
const next = new URLSearchParams(params)
if (id == null) next.delete('thread')
else next.set('thread', String(id))
setParams(next)
}
const { loading, forum } = state
if (loading || !forum) return null
if (openThreadId && thread) {
return (
<ThreadView
thread={thread}
canModerate={forum.canModerate}
onBack={() => openThread(null)}
onModerate={async (action) => {
await api.teamForumModerate(team.slug, thread.id, { action })
await loadThreads(team.slug)
openThread(null)
}}
/>
)
}
return (
<section style={{ marginTop: 26 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
Announcements
</h2>
{forum.canPost && !composing && (
<button type="button" className="btn-ghost sans" onClick={() => setComposing(true)}>
Post an announcement
</button>
)}
</header>
{composing && (
<Composer
slug={team.slug}
imageMode={imageMode}
onCancel={() => setComposing(false)}
onPosted={async () => {
setComposing(false)
await loadThreads(team.slug)
}}
/>
)}
{forum.threads.length === 0 && !composing && (
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
Nothing has been announced here yet.
</p>
)}
{forum.canModerate && <GuestManager slug={team.slug} />}
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0', display: 'grid', gap: 8 }}>
{forum.threads.map((t) => (
<li key={t.id}>
<button
type="button"
className="sans"
onClick={() => openThread(t.id)}
style={{
background: 'none', border: 0, padding: 0, cursor: 'pointer',
textAlign: 'left', color: 'var(--ink)', font: 'inherit',
}}
>
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
<strong>{t.title}</strong>
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
{t.author}
{t.status === 'hidden' && ' · hidden'}
</span>
</button>
</li>
))}
</ul>
</section>
)
}
/**
* The leader's grant control — §2.5 path 3, exercised by a leader rather than by
* staff.
*
* Worth being explicit about what this admits someone to and what it does not: a
* grant may name ANY account, including one with no linked game character, and it
* writes nothing but the grants ledger. A guest here never appears on the roster,
* never counts towards the Team's membership, and never becomes eligible for a
* Discord role — an integration cannot verify that an unlinked account is a real
* game member, so it must not hand that account a privilege somewhere
* impersonation has consequences.
*
* A leader is capped; staff are not. The cap is shown rather than only enforced,
* because a leader who hits a limit they were never told about reads it as a bug.
*/
function GuestManager({ slug }) {
const [open, setOpen] = useState(false)
const [data, setData] = useState(null)
const [username, setUsername] = useState('')
const [error, setError] = useState(null)
const load = useCallback(async () => {
try {
setData(await api.teamGrantList(slug))
} catch {
setData(null)
}
}, [slug])
useEffect(() => { if (open) load() }, [open, load])
const add = async (event) => {
event.preventDefault()
setError(null)
try {
await api.teamGrantAdd(slug, { username })
setUsername('')
await load()
} catch (err) {
setError(err.message || 'Could not grant access')
}
}
const revoke = async (userId) => {
setError(null)
try {
await api.teamGrantRevoke(slug, userId)
await load()
} catch (err) {
setError(err.message || 'Could not revoke that')
}
}
if (!open) {
return (
<button type="button" className="btn-ghost sans" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
Forum guests
</button>
)
}
return (
<section style={{ marginTop: 12, padding: 12, border: '1px solid var(--rule, #ccc)', borderRadius: 6 }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Forum guests</h3>
<button type="button" className="btn-ghost sans" onClick={() => setOpen(false)}>Close</button>
</header>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '6px 0 10px' }}>
Guests read and post in this forum without being members of the Team. They do not appear on the
roster and are not counted as members.
{data?.cap ? ` Up to ${data.cap} at a time.` : ''}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 10px', display: 'grid', gap: 6 }}>
{(data?.guests || []).map((g) => (
<li key={g.userId} className="sans" style={{ fontSize: '0.88rem', display: 'flex', gap: 8 }}>
<span>{g.username}</span>
<button type="button" className="btn-ghost sans" onClick={() => revoke(g.userId)}>Remove</button>
</li>
))}
{data && data.guests.length === 0 && (
<li className="sans dim" style={{ fontSize: '0.85rem' }}>No guests yet.</li>
)}
</ul>
<form onSubmit={add} style={{ display: 'flex', gap: 8 }}>
<input
className="sans"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Account name"
maxLength={32}
required
/>
<button type="submit" className="btn sans">Add</button>
</form>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
</section>
)
}
function ThreadView({ thread, canModerate, onBack, onModerate }) {
return (
<section style={{ marginTop: 26 }}>
<button type="button" className="btn-ghost sans" onClick={onBack} style={{ marginBottom: 10 }}>
All announcements
</button>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
{thread.title}
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.author}
{thread.authorDeleted && ' (account removed)'}
</p>
{thread.posts.map((post) => (
<article key={post.id} style={{ marginBottom: 16 }}>
{/*
Rendered server-side under the operator's image policy, which is why
this is dangerouslySetInnerHTML and not a sanitizer call here. The body
was sanitised on write with the forum's own profile — one in which
`img` is never allowed — 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.
*/}
{/* eslint-disable-next-line react/no-danger */}
<div className="serif" dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
))}
{canModerate && (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button type="button" className="btn-ghost sans" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="btn-ghost sans" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
</div>
)}
</section>
)
}
function Composer({ slug, imageMode, onCancel, onPosted }) {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumPost(slug, { type: 'announcement', title, body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
} finally {
setBusy(false)
}
}
const attach = async (event) => {
const file = event.target.files?.[0]
if (!file) return
try {
const { url } = await api.teamForumUpload(slug, file)
// The URL goes into the BODY as text, not as an <img> tag. The author never
// writes markup here — core decides at render time whether a URL becomes a
// picture, which is what makes the operator's image policy enforceable
// rather than decorative.
setBody((current) => `${current}${current ? '\n\n' : ''}${url}`)
} catch (err) {
setError(err.message || 'Could not upload that')
}
}
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
<input
className="sans"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
maxLength={200}
required
/>
<textarea
className="sans"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your announcement. Paste an image URL on its own line to share a picture."
rows={6}
required
/>
{imageMode === 'uploads' && (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
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>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn sans" disabled={busy}>Post</button>
<button type="button" className="btn-ghost sans" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}

View File

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