diff --git a/client/src/App.jsx b/client/src/App.jsx index 2796c51..9deccb3 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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() { } /> } /> } /> + + + + } + > + } /> + } /> + } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 1c87b58..be66a86 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -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' }), diff --git a/client/src/components/RoleGate.jsx b/client/src/components/RoleGate.jsx new file mode 100644 index 0000000..bd4df0a --- /dev/null +++ b/client/src/components/RoleGate.jsx @@ -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 + return children +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index ac0c82c..2a2e90b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -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() {