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>
312 lines
11 KiB
JavaScript
312 lines
11 KiB
JavaScript
import { useCallback, useState } from 'react'
|
||
import { useParams, Link } from 'react-router-dom'
|
||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||
import { useAsync } from '../../../lib/useAsync.js'
|
||
import { dateTime, ago } from '../../../lib/format.js'
|
||
import { api } from '../../../api/client.js'
|
||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||
|
||
const ACTION_TABS = [
|
||
{ key: 'warn', label: 'Warnings' },
|
||
{ key: 'mute', label: 'Mutes' },
|
||
{ key: 'kick', label: 'Kicks' },
|
||
{ key: 'ban', label: 'Bans' },
|
||
]
|
||
|
||
function fmtDuration(seconds) {
|
||
if (!seconds) return null
|
||
if (seconds % 86400 === 0) return `${seconds / 86400}d`
|
||
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||
return `${seconds}s`
|
||
}
|
||
|
||
export default function ModerationUser() {
|
||
const { discordId } = useParams()
|
||
const { user } = useAuth()
|
||
const isAdmin = user?.role === 'admin'
|
||
const [tab, setTab] = useState('warn')
|
||
const [tick, setTick] = useState(0)
|
||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||
|
||
const { loading, error, data } = useAsync(
|
||
() =>
|
||
Promise.all([
|
||
api.admin.modUser(discordId),
|
||
api.admin.modUserActions(discordId, { limit: 200 }),
|
||
api.admin.modUserNotes(discordId),
|
||
api.admin.getUserAppeals(discordId),
|
||
]),
|
||
[discordId, tick],
|
||
)
|
||
|
||
if (loading) return <Loading />
|
||
if (error) return <ErrorState message="Could not load this user’s history." />
|
||
|
||
const [summary, actions, notes, appeals] = data
|
||
const counts = summary.counts || {}
|
||
const tabActions = actions.filter((a) => a.action_type === tab)
|
||
|
||
let tabBody
|
||
if (tab === 'notes') {
|
||
tabBody = <NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
|
||
} else if (tab === 'appeals') {
|
||
tabBody = <AppealsTab rows={appeals} />
|
||
} else {
|
||
tabBody = <ActionTable rows={tabActions} showDuration={tab === 'mute'} />
|
||
}
|
||
|
||
return (
|
||
<section>
|
||
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
|
||
← Back to moderation
|
||
</Link>
|
||
|
||
{/* Header */}
|
||
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 20px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
|
||
{summary.tag || '(unknown user)'}
|
||
</span>
|
||
{summary.linked_account && (
|
||
<span className="badge badge-editor">site account: {summary.linked_account.username}</span>
|
||
)}
|
||
</div>
|
||
<div className="sans dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem', marginTop: 4 }}>
|
||
{discordId}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 18, marginTop: 14, flexWrap: 'wrap' }}>
|
||
{ACTION_TABS.map((t) => (
|
||
<Count key={t.key} label={t.label} value={counts[t.key] || 0} />
|
||
))}
|
||
<Count label="Notes" value={summary.notes_count || 0} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14, borderBottom: '1px solid var(--line-soft)', paddingBottom: 12 }}>
|
||
{ACTION_TABS.map((t) => (
|
||
<TabButton key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||
{t.label} ({counts[t.key] || 0})
|
||
</TabButton>
|
||
))}
|
||
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
|
||
Notes ({summary.notes_count || 0})
|
||
</TabButton>
|
||
<TabButton active={tab === 'appeals'} onClick={() => setTab('appeals')}>
|
||
Appeals ({appeals.length})
|
||
</TabButton>
|
||
</div>
|
||
|
||
{tabBody}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function Count({ label, value }) {
|
||
return (
|
||
<div>
|
||
<div className="display" style={{ fontSize: '1.4rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
|
||
<div className="card-kicker" style={{ marginTop: 4, marginBottom: 0 }}>{label}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TabButton({ active, onClick, children }) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className="sans"
|
||
style={{
|
||
border: '1px solid var(--line)',
|
||
borderRadius: 8,
|
||
padding: '7px 14px',
|
||
cursor: 'pointer',
|
||
fontSize: '0.85rem',
|
||
background: active ? 'var(--blue)' : 'transparent',
|
||
color: active ? 'var(--ink)' : 'var(--muted)',
|
||
borderColor: active ? 'var(--accent)' : 'var(--line)',
|
||
}}
|
||
>
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function ActionTable({ rows, showDuration }) {
|
||
return (
|
||
<div className="panel-flat">
|
||
<table className="adm-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="adm-th">Reason</th>
|
||
<th className="adm-th">Actor</th>
|
||
{showDuration && <th className="adm-th">Duration</th>}
|
||
<th className="adm-th">When</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.length === 0 && (
|
||
<tr>
|
||
<td className="adm-td" colSpan={showDuration ? 4 : 3} style={{ color: 'var(--muted)' }}>
|
||
Nothing here.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{rows.map((a) => (
|
||
<tr key={a.id}>
|
||
<td className="adm-td" style={{ color: 'var(--text)' }}>{a.reason || '—'}</td>
|
||
<td className="adm-td">
|
||
{a.is_automated ? (
|
||
<span className="badge badge-auto">Automated</span>
|
||
) : (
|
||
<span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>
|
||
)}
|
||
</td>
|
||
{showDuration && <td className="adm-td dim">{fmtDuration(a.duration_seconds) || '—'}</td>}
|
||
<td className="adm-td dim" title={dateTime(a.created_at)}>{dateTime(a.created_at)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const APPEAL_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 APPEAL_STATUS_LABEL = {
|
||
pending: 'Pending',
|
||
under_review: 'Under review',
|
||
approved: 'Approved',
|
||
denied: 'Denied',
|
||
withdrawn: 'Withdrawn',
|
||
}
|
||
|
||
function AppealsTab({ rows }) {
|
||
return (
|
||
<div className="panel-flat">
|
||
<table className="adm-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="adm-th">Action</th>
|
||
<th className="adm-th">Appeal</th>
|
||
<th className="adm-th">Staff response</th>
|
||
<th className="adm-th">Status</th>
|
||
<th className="adm-th">Reversal</th>
|
||
<th className="adm-th">When</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.length === 0 && (
|
||
<tr>
|
||
<td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No appeals from this user.</td>
|
||
</tr>
|
||
)}
|
||
{rows.map((a) => (
|
||
<tr key={a.id}>
|
||
<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: 260, whiteSpace: 'pre-wrap' }}>{a.submitted_text}</td>
|
||
<td className="adm-td dim" style={{ maxWidth: 220, whiteSpace: 'pre-wrap' }}>{a.staff_response || '—'}</td>
|
||
<td className="adm-td">
|
||
<span className="badge" style={APPEAL_STATUS_STYLE[a.status]}>{APPEAL_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 dim" title={dateTime(a.submitted_at)}>{ago(a.submitted_at)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function NotesTab({ discordId, notes, isAdmin, onAdded }) {
|
||
const [body, setBody] = useState('')
|
||
const [visibility, setVisibility] = useState('staff_only')
|
||
const [busy, setBusy] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
|
||
async function add() {
|
||
if (!body.trim()) return
|
||
setBusy(true)
|
||
setErr('')
|
||
try {
|
||
await api.admin.addModNote(discordId, { body: body.trim(), visibility })
|
||
setBody('')
|
||
setVisibility('staff_only')
|
||
onAdded()
|
||
} catch (e) {
|
||
setErr(e.message || 'Could not save the note.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ marginBottom: 18 }}>
|
||
{err && <p className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{err}</p>}
|
||
<textarea
|
||
className="textarea"
|
||
placeholder="Add a staff note about this user…"
|
||
value={body}
|
||
onChange={(e) => setBody(e.target.value)}
|
||
rows={3}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
|
||
<select value={visibility} onChange={(e) => setVisibility(e.target.value)} className="select" style={{ maxWidth: 200 }}>
|
||
<option value="staff_only">Staff only</option>
|
||
{isAdmin && <option value="admin_only">Admin only</option>}
|
||
</select>
|
||
<button onClick={add} disabled={busy || !body.trim()} className="btn btn-primary btn-sq">
|
||
{busy ? 'Saving…' : 'Add note'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel-flat">
|
||
<table className="adm-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="adm-th">Note</th>
|
||
<th className="adm-th">Author</th>
|
||
<th className="adm-th">Visibility</th>
|
||
<th className="adm-th">When</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{notes.length === 0 && (
|
||
<tr>
|
||
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>No notes yet.</td>
|
||
</tr>
|
||
)}
|
||
{notes.map((n) => (
|
||
<tr key={n.id}>
|
||
<td className="adm-td" style={{ color: 'var(--text)', whiteSpace: 'pre-wrap' }}>{n.body}</td>
|
||
<td className="adm-td dim">{n.author_username || n.author_tag || '—'}</td>
|
||
<td className="adm-td">
|
||
<span className={`badge ${n.visibility === 'admin_only' ? 'badge-ban' : 'badge-editor'}`}>
|
||
{n.visibility === 'admin_only' ? 'admin only' : 'staff'}
|
||
</span>
|
||
</td>
|
||
<td className="adm-td dim" title={dateTime(n.created_at)}>{ago(n.created_at)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|