Files
website/client/src/routes/admin/views/SettingsAdmin.jsx
wtclaude 12d50fd615
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
chore(quality): resolve SonarQube code smells across website
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.

By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
  drops under the threshold — shard model upsert builders, page/wiki update,
  block validation, notification stream mapping (dispatch table), SSO mobile
  login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
  command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
  CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
  a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
  components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
  (two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D.  S125 (5): reword state-shape comments that
  parsed as code.  S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
  prefix list): assorted one-liners.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 04:35:39 -05:00

162 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { lazy, Suspense, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx'
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
// Editable settings shown on this screen (key -> label + control type).
const FIELDS = [
{ key: 'site_title', label: 'Site title' },
{
key: 'homepage_teaser',
label: 'Homepage teaser',
rich: true,
help: 'Rich text shown under the hero heading on the portal (when no custom hero layout is published).',
},
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
{ key: 'status_message', label: 'Status message' },
{
key: 'contact_email',
label: 'Contact email',
help: 'Where contact-form messages (and test emails) are delivered. Also the address shown when email delivery is unconfigured and the form falls back to a mailto: link.',
},
{
key: 'player_registration',
label: 'Player registration',
help: 'Who can create a player account, and how. Off by default.',
options: [
{ value: 'disabled', label: 'Disabled — no self-registration' },
{ value: 'password', label: 'Password — username + password sign-up' },
{ value: 'sso', label: 'SSO — sign up with a linked provider' },
{ value: 'both', label: 'Both — password and SSO' },
],
fallback: 'disabled',
},
{
key: 'game_account_signup',
label: 'Game-account creation',
help: 'Whether players can create a GAME account (for the game client) from the site. The game servers own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
options: [
{ value: 'disabled', label: 'Disabled — link an existing account only' },
{ value: 'website', label: 'Website — the site creates game accounts' },
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
{ value: 'game', label: 'Game only — created in the game client, not the site' },
],
fallback: 'disabled',
},
]
export default function SettingsAdmin() {
const { refresh: refreshSite } = useSite()
const [values, setValues] = useState(null)
const [initial, setInitial] = useState({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
let active = true
api.admin
.getSettings()
.then((all) => {
if (!active) return
const v = {}
FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? f.fallback ?? ''))
setValues(v)
setInitial(v)
})
.catch(() => active && setError('Could not load settings.'))
.finally(() => active && setLoading(false))
return () => {
active = false
}
}, [])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
// setRaw takes the next value directly (rich editor onChange), set adapts a
// DOM change event onto it.
const setRaw = (k) => (val) => {
setValues((v) => ({ ...v, [k]: val }))
setSaved(false)
}
const set = (k) => (e) => setRaw(k)(e.target.value)
async function save() {
setBusy(true)
setError('')
try {
await api.admin.updateSettings(values)
setInitial(values)
setSaved(true)
await refreshSite()
} catch (err) {
setError(err.message || 'Could not save settings.')
} finally {
setBusy(false)
}
}
return (
<section style={{ maxWidth: 620 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{FIELDS.map((f) => {
// A rich field can't live inside a <label> (nested toolbar buttons +
// contenteditable), so it uses a plain <div> wrapper instead.
const Wrap = f.rich ? 'div' : 'label'
let field
if (f.rich) {
field = (
<Suspense fallback={<span className="spin" />}>
<RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" />
</Suspense>
)
} else if (f.options) {
field = (
<select value={values[f.key]} onChange={set(f.key)} className="select">
{f.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)
} else if (f.long) {
field = <textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
} else {
field = <input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
}
return (
<Wrap key={f.key} style={{ display: 'block' }}>
<span className="field-label">{f.label}</span>
{field}
{f.help && (
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
{f.help}
</span>
)}
</Wrap>
)
})}
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save changes'}
</button>
<button onClick={() => setValues(initial)} disabled={busy} className="pill">
Reset
</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>
</div>
<EmailDelivery />
</section>
)
}