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>
292 lines
13 KiB
JavaScript
292 lines
13 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
|
import { describe } from '../../../lib/shardEvents.js'
|
|
import { ago } from '../../../lib/format.js'
|
|
import { api } from '../../../api/client.js'
|
|
|
|
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
|
|
// unban) and the help-page support queue, plus a live audit log. Open to admins
|
|
// and moderators. The acting staff member (`actor`) is attached server-side from
|
|
// the session — nothing here sends it — so every action is attributable.
|
|
|
|
function Flash({ ok, err }) {
|
|
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
|
|
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
|
|
return null
|
|
}
|
|
|
|
// ── Broadcast ────────────────────────────────────────────────────────────────
|
|
function Broadcast() {
|
|
const [text, setText] = useState('')
|
|
const [hue, setHue] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [ok, setOk] = useState('')
|
|
const [err, setErr] = useState('')
|
|
|
|
async function send() {
|
|
if (!text.trim()) return setErr('Enter a message.')
|
|
setBusy(true); setOk(''); setErr('')
|
|
try {
|
|
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
|
|
setOk('Broadcast sent.')
|
|
setText('')
|
|
} catch (e) {
|
|
setErr(e.message || 'Could not broadcast.')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
|
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
|
A system message shown to everyone online right now.
|
|
</p>
|
|
<label style={{ display: 'block' }}>
|
|
<span className="field-label">Message</span>
|
|
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
|
|
</label>
|
|
<label style={{ display: 'block', maxWidth: 140 }}>
|
|
<span className="field-label">Hue (optional)</span>
|
|
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
|
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
|
|
<Flash ok={ok} err={err} />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
|
|
function AccountActions() {
|
|
const [account, setAccount] = useState('')
|
|
const [durationSec, setDurationSec] = useState('')
|
|
const [reason, setReason] = useState('')
|
|
const [busy, setBusy] = useState('')
|
|
const [ok, setOk] = useState('')
|
|
const [err, setErr] = useState('')
|
|
|
|
const acct = account.trim()
|
|
function guard() {
|
|
if (!acct) {
|
|
setErr('Enter an account name.')
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function run(label, fn, done) {
|
|
if (!guard()) return
|
|
setBusy(label); setOk(''); setErr('')
|
|
try {
|
|
const r = await fn()
|
|
setOk(done(r))
|
|
} catch (e) {
|
|
setErr(e.message || 'Action failed.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
const kick = () =>
|
|
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
|
|
const n = r?.sessions != null ? r.sessions : null
|
|
const plural = n === 1 ? '' : 's'
|
|
const sessions = n != null ? ` (${n} session${plural})` : ''
|
|
return `Kicked ${acct}${sessions}.`
|
|
})
|
|
const ban = () =>
|
|
run(
|
|
'ban',
|
|
() =>
|
|
api.admin.shardOps.ban({
|
|
account: acct,
|
|
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
|
reason: reason.trim() || undefined,
|
|
}),
|
|
() => {
|
|
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
|
return `Banned ${acct}${when}.`
|
|
},
|
|
)
|
|
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
|
|
|
|
return (
|
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
|
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
|
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
|
|
</p>
|
|
<label style={{ display: 'block' }}>
|
|
<span className="field-label">Account</span>
|
|
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
<label style={{ display: 'block', maxWidth: 200 }}>
|
|
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
|
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
|
|
</label>
|
|
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
|
|
<span className="field-label">Ban reason (optional)</span>
|
|
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
|
</label>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
|
|
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
|
|
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
|
|
<Flash ok={ok} err={err} />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── Support (help-page) queue ────────────────────────────────────────────────
|
|
function PageRow({ page, onDone }) {
|
|
const [message, setMessage] = useState('')
|
|
const [busy, setBusy] = useState('')
|
|
const [err, setErr] = useState('')
|
|
|
|
async function respond(close) {
|
|
if (!message.trim()) return setErr('Enter a reply first.')
|
|
setBusy(close ? 'respond-close' : 'respond'); setErr('')
|
|
try {
|
|
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
|
|
onDone()
|
|
} catch (e) {
|
|
setErr(e.message || 'Could not send.')
|
|
setBusy('')
|
|
}
|
|
}
|
|
async function close() {
|
|
setBusy('close'); setErr('')
|
|
try {
|
|
await api.admin.shardOps.closePage(page.pageId)
|
|
onDone()
|
|
} catch (e) {
|
|
setErr(e.message || 'Could not close.')
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
|
<div style={{ minWidth: 0 }}>
|
|
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
|
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
|
{page.sender?.name || page.pageId}
|
|
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
|
|
</div>
|
|
</div>
|
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
|
|
</div>
|
|
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
|
|
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
|
|
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
|
|
</div>
|
|
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
|
|
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
|
|
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
|
|
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SupportQueue() {
|
|
const [pages, setPages] = useState(null)
|
|
const [err, setErr] = useState('')
|
|
const pollRef = useRef(null)
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setPages(await api.admin.shardOps.pages())
|
|
} catch {
|
|
setErr('Could not load the support queue.')
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
load()
|
|
pollRef.current = setInterval(load, 7000)
|
|
return () => clearInterval(pollRef.current)
|
|
}, [load])
|
|
|
|
let queueBody
|
|
if (pages == null) {
|
|
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
|
} else if (pages.length === 0) {
|
|
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
|
|
} else {
|
|
queueBody = (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
|
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
|
Open help pages from players. A reply reaches them in game (or on their next login).
|
|
</p>
|
|
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
|
|
{queueBody}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── Audit log ────────────────────────────────────────────────────────────────
|
|
// Seeded from the stored admin.audit history, then kept live from the admin SSE
|
|
// channel (which carries every kind — we filter to admin.audit here).
|
|
function AuditLog() {
|
|
const [seed, setSeed] = useState([])
|
|
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
|
|
|
|
useEffect(() => {
|
|
api.admin.shardOps
|
|
.audit(50)
|
|
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
|
|
.catch(() => setSeed([]))
|
|
}, [])
|
|
|
|
// Live events on top; fall back to the seed for anything older than the live tail.
|
|
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
|
|
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
|
|
|
|
return (
|
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
|
|
{rows.length === 0 ? (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
|
|
) : (
|
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
|
|
{rows.map((e) => (
|
|
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export default function ShardOps() {
|
|
return (
|
|
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
|
<Broadcast />
|
|
<AccountActions />
|
|
<SupportQueue />
|
|
<AuditLog />
|
|
</section>
|
|
)
|
|
}
|