From cbb7339a3a73b3052aa1360e4f3161d0becdce51 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 07:24:24 -0500 Subject: [PATCH] feat(teams): the forum panel core fills, and the operator's controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- client/src/api/client.js | 27 ++ client/src/main.jsx | 9 + client/src/modules/TeamForumPanel.jsx | 382 ++++++++++++++++++ .../src/routes/admin/views/SettingsAdmin.jsx | 3 + .../routes/admin/views/TeamForumSettings.jsx | 248 ++++++++++++ 5 files changed, 669 insertions(+) create mode 100644 client/src/modules/TeamForumPanel.jsx create mode 100644 client/src/routes/admin/views/TeamForumSettings.jsx diff --git a/client/src/api/client.js b/client/src/api/client.js index d35a67e..447803d 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -152,6 +152,26 @@ export const api = { if (opts.offset != null) qs.set('offset', String(opts.offset)) 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'), wikiPage: (slug) => req(`/public/wiki/${slug}`), // 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 }), clearTeamLeaderOverride: (id, memberKey) => 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'), teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`), decideTeamRequest: (id, status, note) => diff --git a/client/src/main.jsx b/client/src/main.jsx index 258fbbd..0d26693 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -5,6 +5,7 @@ import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.js' import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js' import TeamActivityFeed from './modules/TeamActivityFeed.jsx' +import TeamForumPanel from './modules/TeamForumPanel.jsx' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -75,6 +76,14 @@ declareSlot('player.invite.accepted') // unfilled slot rendering nothing. 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 // of core's boot the module system changes. // diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx new file mode 100644 index 0000000..d961654 --- /dev/null +++ b/client/src/modules/TeamForumPanel.jsx @@ -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 ( + openThread(null)} + onModerate={async (action) => { + await api.teamForumModerate(team.slug, thread.id, { action }) + await loadThreads(team.slug) + openThread(null) + }} + /> + ) + } + + return ( +
+
+

+ Announcements +

+ {forum.canPost && !composing && ( + + )} +
+ + {composing && ( + setComposing(false)} + onPosted={async () => { + setComposing(false) + await loadThreads(team.slug) + }} + /> + )} + + {forum.threads.length === 0 && !composing && ( +

+ Nothing has been announced here yet. +

+ )} + + {forum.canModerate && } + +
    + {forum.threads.map((t) => ( +
  • + +
  • + ))} +
+
+ ) +} + +/** + * 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 ( + + ) + } + + return ( +
+
+

Forum guests

+ +
+

+ 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.` : ''} +

+ +
    + {(data?.guests || []).map((g) => ( +
  • + {g.username} + +
  • + ))} + {data && data.guests.length === 0 && ( +
  • No guests yet.
  • + )} +
+ +
+ setUsername(e.target.value)} + placeholder="Account name" + maxLength={32} + required + /> + +
+ {error &&

{error}

} +
+ ) +} + +function ThreadView({ thread, canModerate, onBack, onModerate }) { + return ( +
+ +

+ {thread.title} +

+

+ {thread.author} + {thread.authorDeleted && ' (account removed)'} +

+ + {thread.posts.map((post) => ( +
+ {/* + 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 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 */} +
+
+ ))} + + {canModerate && ( +
+ + +
+ )} +
+ ) +} + +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 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 ( +
+ setTitle(e.target.value)} + placeholder="Title" + maxLength={200} + required + /> +