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>
289 lines
10 KiB
JavaScript
289 lines
10 KiB
JavaScript
import { useCallback, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import Modal from '../../../components/Modal.jsx'
|
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
|
import { useAsync } from '../../../lib/useAsync.js'
|
|
import { ago, dateTime } from '../../../lib/format.js'
|
|
import { api } from '../../../api/client.js'
|
|
|
|
// Staff queue for moderation appeals (bans/mutes appealed by players). Mirrors
|
|
// the Moderation.jsx tile/feed layout: a status-filter segmented control over a
|
|
// flat table, with per-row Claim / Resolve actions. Resolve opens a modal — no
|
|
// browser confirm()/alert() anywhere here.
|
|
|
|
const STATUS_TABS = [
|
|
{ key: 'open', label: 'Open', param: undefined },
|
|
{ key: 'pending', label: 'Pending', param: 'pending' },
|
|
{ key: 'under_review', label: 'Under review', param: 'under_review' },
|
|
{ key: 'approved', label: 'Approved', param: 'approved' },
|
|
{ key: 'denied', label: 'Denied', param: 'denied' },
|
|
{ key: 'withdrawn', label: 'Withdrawn', param: 'withdrawn' },
|
|
{ key: 'all', label: 'All', param: 'all' },
|
|
]
|
|
|
|
const STATUS_STYLE = {
|
|
pending: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
|
|
under_review: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
|
|
approved: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
|
|
denied: { color: '#d98b84', background: 'rgba(217,139,132,0.16)', border: '1px solid rgba(217,139,132,0.4)' },
|
|
withdrawn: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
|
|
}
|
|
const STATUS_LABEL = {
|
|
pending: 'Pending',
|
|
under_review: 'Under review',
|
|
approved: 'Approved',
|
|
denied: 'Denied',
|
|
withdrawn: 'Withdrawn',
|
|
}
|
|
|
|
function excerpt(text, n = 90) {
|
|
if (!text) return ''
|
|
return text.length > n ? `${text.slice(0, n)}…` : text
|
|
}
|
|
|
|
export default function Appeals() {
|
|
const navigate = useNavigate()
|
|
const [tab, setTab] = useState('open')
|
|
const [tick, setTick] = useState(0)
|
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
|
const [busyId, setBusyId] = useState('')
|
|
const [resolving, setResolving] = useState(null) // the appeal being resolved
|
|
const [notice, setNotice] = useState(null) // fields text and tone
|
|
|
|
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
|
|
const { loading, error, data } = useAsync(
|
|
() => api.admin.getAppeals({ status: activeTab.param, limit: 100 }),
|
|
[tab, tick],
|
|
)
|
|
|
|
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
|
|
|
|
async function claim(appeal) {
|
|
setBusyId(appeal.id)
|
|
setNotice(null)
|
|
try {
|
|
await api.admin.claimAppeal(appeal.id)
|
|
reload()
|
|
} catch (err) {
|
|
setNotice({ text: err.message || 'Could not claim this appeal.', tone: 'error' })
|
|
} finally {
|
|
setBusyId('')
|
|
}
|
|
}
|
|
|
|
function onResolved(appeal, result) {
|
|
setResolving(null)
|
|
const { reversal } = result
|
|
if (reversal?.attempted && reversal.ok) {
|
|
setNotice({ text: `Discord ${appeal.action_type} lifted.`, tone: 'ok' })
|
|
} else if (reversal?.attempted && !reversal.ok) {
|
|
setNotice({ text: 'Reversal failed — reverse manually in Discord.', tone: 'error' })
|
|
} else {
|
|
setNotice(null)
|
|
}
|
|
reload()
|
|
}
|
|
|
|
if (loading) return <Loading />
|
|
if (error) return <ErrorState message="Could not load appeals." />
|
|
|
|
const rows = data || []
|
|
|
|
return (
|
|
<section>
|
|
{/* Status filter */}
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
|
|
{STATUS_TABS.map((t) => (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => setTab(t.key)}
|
|
className="pill"
|
|
style={tab === t.key ? activePill : undefined}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{notice && (
|
|
<p
|
|
className="sans"
|
|
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
|
|
>
|
|
{notice.text}
|
|
</p>
|
|
)}
|
|
|
|
<div className="panel-flat">
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">Target</th>
|
|
<th className="adm-th">Action</th>
|
|
<th className="adm-th">Appeal</th>
|
|
<th className="adm-th">Submitted by</th>
|
|
<th className="adm-th">Age</th>
|
|
<th className="adm-th">Status</th>
|
|
<th className="adm-th">Reversal</th>
|
|
<th className="adm-th" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length === 0 && (
|
|
<tr>
|
|
<td className="adm-td" colSpan={8} style={muted}>
|
|
No appeals match this filter.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{rows.map((a) => (
|
|
<tr key={a.id}>
|
|
<td className="adm-td">
|
|
<span className="link-accent" onClick={() => goUser(a.discord_user_id)}>
|
|
{a.action_target_tag || a.discord_user_id}
|
|
</span>
|
|
</td>
|
|
<td className="adm-td">
|
|
<span className={`badge badge-${a.action_type}`}>{a.action_type}</span>
|
|
</td>
|
|
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 320 }}>
|
|
{excerpt(a.submitted_text)}
|
|
</td>
|
|
<td className="adm-td dim">{a.submitter_username || '—'}</td>
|
|
<td className="adm-td dim" title={dateTime(a.submitted_at)}>{ago(a.submitted_at)}</td>
|
|
<td className="adm-td">
|
|
<span className="badge" style={STATUS_STYLE[a.status]}>{STATUS_LABEL[a.status] || a.status}</span>
|
|
</td>
|
|
<td className="adm-td dim">
|
|
{a.reversal_status === 'done' && <span style={{ color: '#7fd0a4' }}>Lifted</span>}
|
|
{a.reversal_status === 'failed' && <span style={{ color: '#d98b84' }}>Failed</span>}
|
|
{(!a.reversal_status || a.reversal_status === 'none') && '—'}
|
|
</td>
|
|
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
|
{a.status === 'pending' && (
|
|
<button
|
|
onClick={() => claim(a)}
|
|
disabled={busyId === a.id}
|
|
className="pill"
|
|
style={{ marginRight: 6 }}
|
|
>
|
|
{busyId === a.id ? 'Claiming…' : 'Claim'}
|
|
</button>
|
|
)}
|
|
{(a.status === 'pending' || a.status === 'under_review') && (
|
|
<button onClick={() => setResolving(a)} className="btn btn-primary btn-sq" style={{ padding: '5px 12px', fontSize: '0.82rem' }}>
|
|
Resolve
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{resolving && (
|
|
<ResolveModal appeal={resolving} onClose={() => setResolving(null)} onResolved={onResolved} />
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function ResolveModal({ appeal, onClose, onResolved }) {
|
|
const [status, setStatus] = useState('approved')
|
|
const [staffResponse, setStaffResponse] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
async function submit() {
|
|
setBusy(true)
|
|
setError('')
|
|
try {
|
|
const result = await api.admin.resolveAppeal(appeal.id, {
|
|
status,
|
|
staff_response: staffResponse.trim() || undefined,
|
|
})
|
|
onResolved(appeal, result)
|
|
} catch (err) {
|
|
setError(err.message || 'Could not resolve this appeal.')
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const verb = status === 'approved' ? 'approved' : 'denied'
|
|
|
|
return (
|
|
<Modal
|
|
title={`Resolve appeal — ${appeal.action_target_tag || appeal.discord_user_id}`}
|
|
onClose={onClose}
|
|
width={560}
|
|
footer={
|
|
<>
|
|
<button onClick={onClose} disabled={busy} className="pill">
|
|
Cancel
|
|
</button>
|
|
<button onClick={submit} disabled={busy} className="btn btn-primary btn-sq">
|
|
{busy ? 'Saving…' : `Mark ${verb}`}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
|
|
|
<div>
|
|
<span className="field-label">Submitted appeal</span>
|
|
<div
|
|
className="sans"
|
|
style={{
|
|
marginTop: 6,
|
|
padding: '10px 12px',
|
|
border: '1px solid var(--line)',
|
|
borderRadius: 8,
|
|
color: 'var(--text)',
|
|
fontSize: '0.86rem',
|
|
whiteSpace: 'pre-wrap',
|
|
maxHeight: 200,
|
|
overflow: 'auto',
|
|
}}
|
|
>
|
|
{appeal.submitted_text}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 10 }}>
|
|
<button
|
|
onClick={() => setStatus('approved')}
|
|
className="pill"
|
|
style={status === 'approved' ? { background: 'var(--blue)', color: 'var(--ink)', borderColor: '#7fd0a4' } : undefined}
|
|
>
|
|
Approve
|
|
</button>
|
|
<button
|
|
onClick={() => setStatus('denied')}
|
|
className="pill"
|
|
style={status === 'denied' ? { background: 'var(--blue)', color: 'var(--ink)', borderColor: '#d98b84' } : undefined}
|
|
>
|
|
Deny
|
|
</button>
|
|
</div>
|
|
|
|
<label>
|
|
<span className="field-label">Staff response (optional)</span>
|
|
<textarea
|
|
className="textarea"
|
|
placeholder="Message shown to the player…"
|
|
value={staffResponse}
|
|
onChange={(e) => setStaffResponse(e.target.value)}
|
|
rows={4}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
|
|
const muted = { color: 'var(--muted)' }
|