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

312 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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