feat(moderation): appeals (6c) + Discord reversal on approve (6d)
Players whose linked Discord identity was banned or muted can now submit an appeal from the portal and track it; staff get a queue in the admin moderation section to claim and resolve (approve/deny) appeals. Approving a ban/mute appeal best-effort asks the Discord bot to reverse the action (unban / clear timeout) via the internal API and posts a mod-log embed; a down bot never fails the resolution (reversal_status is recorded). - Schema: new server-owned `appeals` table (no cross-owner FK to mod_actions; existence validated in app code). - Server: model/appeals/* + player appeals controller (submit/mine/ eligible/withdraw) and admin queue handlers (list/claim/resolve/ per-user) under the existing admin+moderator gate; one-active-appeal enforced app-side; eligibility keyed on the caller's linked Discord id. - 6d: bot POST /internal/mod-reverse (+ modLog.postReversal) and server botInternalClient.reverseModAction, wired into resolve(). - Client: admin Appeals queue + resolve modal, ModerationUser appeals tab, player Appeals page (submit/withdraw), nav + routes + api methods. - Docs: swagger annotations + component schemas, regenerated output. - Tests: appeals controller + pure suites (server npm test 224 green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
This commit is contained in:
286
client/src/routes/admin/views/Appeals.jsx
Normal file
286
client/src/routes/admin/views/Appeals.jsx
Normal file
@@ -0,0 +1,286 @@
|
||||
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) // { text, 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 ${status === 'approved' ? 'approved' : 'denied'}`}
|
||||
</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)' }
|
||||
Reference in New Issue
Block a user