Add moderation dashboard, user history & notes (Phase 6a)
Surface the Discord bot's moderation data on the admin panel: a read-only
staff dashboard over the existing mod_actions log, per-user history, staff
notes, and a new moderator role. No bot changes.
Schema
- users.role ENUM gains 'moderator' (CREATE + idempotent ALTER for existing DBs)
- new server-owned mod_notes table (staff_only/admin_only visibility)
Server
- model/moderation: read mod_actions via the shared pool (documented read-only
cross of the bot/server ownership boundary), correlate accounts through
user_identities (provider='discord'), flag automated actions via
staff_user_id === bot_config.application_id; pure reshaping helpers isolated
in moderation.pure.js so they unit-test without opening a DB pool
- model/modNotes: list/add with role-gated admin_only visibility
- admin/moderation.controller + routes under /api/v1/admin/moderation/* gated by
requireRole('admin','moderator'); admin_only note writes require admin
- allow assigning 'moderator' in the user create/update validators
Client
- /admin/moderation overview (window tiles, type-filterable recent feed, user
lookup) and /user/:discordId history (tabs + notes with add-note)
- RoleGate; AdminLayout filters nav and confines moderators to their section
- moderator badge + action-type/auto badges
Deferred (see plan): 6b bot event capture (joins/leaves/filter/spam), 6c appeals
(needs public accounts), 6d /internal/mod-reverse bot reversal callback.
Verified: 116 server unit tests, client build, DB-backed model smoke, full
HTTP/RBAC e2e, and a browser click-through of the dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
@@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
|
||||
import { SiteProvider } from './contexts/SiteContext.jsx'
|
||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||
import RequireAuth from './components/RequireAuth.jsx'
|
||||
import RoleGate from './components/RoleGate.jsx'
|
||||
|
||||
// Public
|
||||
import Portal from './routes/public/Portal.jsx'
|
||||
@@ -31,6 +32,8 @@ import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -73,6 +76,17 @@ export default function App() {
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route
|
||||
path="moderation"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<Outlet />
|
||||
</RoleGate>
|
||||
}
|
||||
>
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
</Route>
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
|
||||
@@ -120,6 +120,30 @@ export const api = {
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- moderation dashboard (admin + moderator) -----
|
||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||
modRecent: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
||||
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
||||
modUserActions: (discordId, params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
|
||||
},
|
||||
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
||||
addModNote: (discordId, data) =>
|
||||
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
||||
|
||||
// ----- account security (self-service 2FA) -----
|
||||
getAccount: () => req('/admin/account'),
|
||||
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||
|
||||
11
client/src/components/RoleGate.jsx
Normal file
11
client/src/components/RoleGate.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
|
||||
// Client-side role gate for admin sub-sections. Real enforcement is server-side
|
||||
// (requireRole); this just keeps the UI honest — a user without one of `roles`
|
||||
// is redirected rather than shown a page that will only 403 on every call.
|
||||
export default function RoleGate({ roles, children, redirect = '/admin' }) {
|
||||
const { user } = useAuth()
|
||||
if (user && !roles.includes(user.role)) return <Navigate to={redirect} replace />
|
||||
return children
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import MoonDot from '../../components/MoonDot.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// `roles` (when present) restricts which roles see a nav item. Items without it
|
||||
// are shown to admin/editor as before. Moderators are further confined to just
|
||||
// their own section + account security (see the redirect effect below).
|
||||
const NAV = [
|
||||
{ to: '/admin', label: 'Dashboard', end: true },
|
||||
{ to: '/admin/posts', label: 'Posts' },
|
||||
{ to: '/admin/wiki', label: 'Wiki' },
|
||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
||||
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/settings', label: 'Settings' },
|
||||
{ to: '/admin/activity', label: 'Activity' },
|
||||
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
||||
@@ -23,6 +27,7 @@ const TITLES = {
|
||||
'/admin/posts': 'Posts',
|
||||
'/admin/wiki': 'Wiki Pages',
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Bot Activity',
|
||||
@@ -48,11 +53,31 @@ export default function AdminLayout() {
|
||||
const { mode } = useSite()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title = TITLES[location.pathname] || 'Admin'
|
||||
const title =
|
||||
TITLES[location.pathname] ||
|
||||
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
|
||||
// The hero canvas editor needs room — let it use the full content width.
|
||||
const wide = location.pathname === '/admin/hero'
|
||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||
|
||||
// Moderators only get the moderation section + their own account security.
|
||||
const isModerator = user?.role === 'moderator'
|
||||
const navItems = NAV.filter((n) => {
|
||||
if (n.roles && !n.roles.includes(user?.role)) return false
|
||||
if (isModerator) return n.to === '/admin/moderation' || n.to === '/admin/account'
|
||||
return true
|
||||
})
|
||||
|
||||
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
||||
// outside their remit — the API would 403 anyway, so send them to their home.
|
||||
useEffect(() => {
|
||||
if (!isModerator) return
|
||||
const p = location.pathname
|
||||
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
|
||||
navigate('/admin/moderation', { replace: true })
|
||||
}
|
||||
}, [isModerator, location.pathname, navigate])
|
||||
|
||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||
useEffect(() => {
|
||||
const meta = document.createElement('meta')
|
||||
@@ -94,7 +119,7 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{NAV.map((n) => (
|
||||
{navItems.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
|
||||
234
client/src/routes/admin/views/Moderation.jsx
Normal file
234
client/src/routes/admin/views/Moderation.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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'
|
||||
|
||||
const WINDOWS = [
|
||||
{ key: '24h', label: 'Last 24h' },
|
||||
{ key: '7d', label: 'Last 7 days' },
|
||||
{ key: '30d', label: 'Last 30 days' },
|
||||
]
|
||||
const TYPES = [
|
||||
{ key: null, label: 'All' },
|
||||
{ key: 'ban', label: 'Bans' },
|
||||
{ key: 'kick', label: 'Kicks' },
|
||||
{ key: 'mute', label: 'Mutes' },
|
||||
{ key: 'warn', label: 'Warnings' },
|
||||
]
|
||||
const TILE_TYPES = [
|
||||
{ key: 'ban', label: 'Bans' },
|
||||
{ key: 'kick', label: 'Kicks' },
|
||||
{ key: 'mute', label: 'Mutes' },
|
||||
{ key: 'warn', label: 'Warnings' },
|
||||
]
|
||||
|
||||
export default function Moderation() {
|
||||
const navigate = useNavigate()
|
||||
const [win, setWin] = useState('24h')
|
||||
const [typeFilter, setTypeFilter] = useState(null)
|
||||
|
||||
const { loading, error, data } = useAsync(
|
||||
() => Promise.all([api.admin.modSummary(), api.admin.modRecent({ limit: 100 })]),
|
||||
[],
|
||||
)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load moderation data." />
|
||||
|
||||
const [summary, recent] = data
|
||||
const counts = summary.windows?.[win] || { ban: 0, kick: 0, mute: 0, warn: 0 }
|
||||
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
|
||||
|
||||
return (
|
||||
<section>
|
||||
<UserSearch onPick={(id) => navigate(`/admin/moderation/user/${id}`)} />
|
||||
|
||||
{/* Window selector */}
|
||||
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
|
||||
{WINDOWS.map((w) => (
|
||||
<button
|
||||
key={w.key}
|
||||
onClick={() => setWin(w.key)}
|
||||
className="pill"
|
||||
style={win === w.key ? activePill : undefined}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stat tiles */}
|
||||
<div className="grid-4" style={{ gap: 14, marginBottom: 12 }}>
|
||||
{TILE_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 20,
|
||||
border: `1px solid ${typeFilter === t.key ? 'var(--accent)' : 'var(--line)'}`,
|
||||
borderRadius: 12,
|
||||
background: 'var(--panel-grad)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||
{counts[t.key] ?? 0}
|
||||
</div>
|
||||
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t.label}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
|
||||
Joins / leaves, filter hits, spam hits, and invite usage aren’t tracked yet — they arrive
|
||||
when bot event capture lands (Phase 6b).
|
||||
</p>
|
||||
|
||||
{/* Recent activity feed */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.25rem', color: 'var(--head)' }}>
|
||||
Recent actions
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{TYPES.map((t) => (
|
||||
<button
|
||||
key={t.label}
|
||||
onClick={() => setTypeFilter(t.key)}
|
||||
className="pill"
|
||||
style={typeFilter === t.key ? activePill : undefined}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">Target</th>
|
||||
<th className="adm-th">Staff</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
<th className="adm-th">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{feed.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No matching actions.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{feed.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">
|
||||
<span
|
||||
className="link-accent"
|
||||
onClick={() => navigate(`/admin/moderation/user/${a.target_user_id}`)}
|
||||
>
|
||||
{a.target_tag || a.target_user_id}
|
||||
</span>
|
||||
{a.linked_account && (
|
||||
<span className="badge badge-editor" style={{ marginLeft: 8 }}>
|
||||
site: {a.linked_account.username}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>
|
||||
{a.reason || '—'}
|
||||
</td>
|
||||
<td className="adm-td dim" title={dateTime(a.created_at)}>
|
||||
{ago(a.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// User lookup: search by Discord id or a historical username snapshot.
|
||||
function UserSearch({ onPick }) {
|
||||
const [term, setTerm] = useState('')
|
||||
const [results, setResults] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function run(e) {
|
||||
e.preventDefault()
|
||||
const q = term.trim()
|
||||
if (!q) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const rows = await api.admin.modSearch(q)
|
||||
setResults(rows)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search by Discord ID or username…"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
style={{ maxWidth: 360 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||
{busy ? 'Searching…' : 'Look up'}
|
||||
</button>
|
||||
</form>
|
||||
{results && results.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>
|
||||
No moderated users match “{term}”.
|
||||
</p>
|
||||
)}
|
||||
{results && results.length > 0 && (
|
||||
<div className="panel-flat" style={{ marginTop: 10 }}>
|
||||
<table className="adm-table">
|
||||
<tbody>
|
||||
{results.map((r) => (
|
||||
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
|
||||
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>
|
||||
{r.target_user_id}
|
||||
</td>
|
||||
<td className="adm-td dim">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
|
||||
<td className="adm-td dim">last {ago(r.last_seen)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activePill = {
|
||||
background: 'var(--blue)',
|
||||
color: 'var(--ink)',
|
||||
borderColor: 'var(--accent)',
|
||||
}
|
||||
245
client/src/routes/admin/views/ModerationUser.jsx
Normal file
245
client/src/routes/admin/views/ModerationUser.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
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),
|
||||
]),
|
||||
[discordId, tick],
|
||||
)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load this user’s history." />
|
||||
|
||||
const [summary, actions, notes] = data
|
||||
const counts = summary.counts || {}
|
||||
const tabActions = actions.filter((a) => a.action_type === tab)
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
{tab === 'notes' ? (
|
||||
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
|
||||
) : (
|
||||
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -83,6 +83,7 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
||||
<select value={form.role} onChange={set('role')} className="select">
|
||||
<option value="admin">admin</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="moderator">moderator</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import UserEditor from './UserEditor.jsx'
|
||||
|
||||
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
|
||||
|
||||
export default function UsersAdmin() {
|
||||
const [tick, setTick] = useState(0)
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
@@ -16,7 +18,7 @@ export default function UsersAdmin() {
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
Manage admin and editor accounts
|
||||
Manage admin, editor, and moderator accounts
|
||||
</p>
|
||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||
+ Add user
|
||||
@@ -44,7 +46,7 @@ export default function UsersAdmin() {
|
||||
{u.username}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<span className={`badge ${u.role === 'admin' ? 'badge-admin' : 'badge-editor'}`}>{u.role}</span>
|
||||
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
||||
</td>
|
||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
|
||||
@@ -613,6 +613,29 @@ button[disabled] {
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.badge-moderator {
|
||||
background: rgba(224, 176, 112, 0.12);
|
||||
color: #e0b070;
|
||||
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||
}
|
||||
/* Action-type badges for the moderation dashboard. */
|
||||
.badge-ban {
|
||||
background: rgba(217, 139, 132, 0.16);
|
||||
color: #d98b84;
|
||||
border: 1px solid rgba(217, 139, 132, 0.4);
|
||||
}
|
||||
.badge-kick,
|
||||
.badge-mute,
|
||||
.badge-warn {
|
||||
background: rgba(224, 176, 112, 0.12);
|
||||
color: #e0b070;
|
||||
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||
}
|
||||
.badge-auto {
|
||||
background: rgba(127, 153, 189, 0.14);
|
||||
color: #9fb0c6;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.link-accent {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
|
||||
Reference in New Issue
Block a user