Files
website/client/src/routes/admin/views/Appeals.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

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)' }