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 (

Team forums

{stale && (

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.

)}
{HELP_TEXT.map((line) =>

{line}

)} {HELP_TAIL.map((line) =>

{line}

)} {mode !== 'disabled' && (

{REMOTE_ADVISORY}

)}
{saved && Saved.} {error && {error}}
{dialog && ( { setDialog(null) setMode(state.imageMode) setEnabled(state.enabled) setEditWindow(String(state.editWindowMinutes ?? 15)) }} onConfirm={async (version) => { setDialog(null) await persist(dialog, version) }} /> )}
) } /** * 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 (

⚠ Image uploads are currently disabled.

Enabling uploads will allow users to store files on your server.

{DIALOG_CHECKS.map((text, i) => ( ))}

{DIALOG_TAIL}

) }