feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
382
client/src/modules/TeamForumPanel.jsx
Normal file
382
client/src/modules/TeamForumPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
248
client/src/routes/admin/views/TeamForumSettings.jsx
Normal file
248
client/src/routes/admin/views/TeamForumSettings.jsx
Normal 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 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 [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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user