diff --git a/client/src/App.jsx b/client/src/App.jsx index 8c428e8..fcb4241 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -18,6 +18,7 @@ import About from './routes/public/About.jsx' import Status from './routes/public/Status.jsx' import Shard from './routes/public/Shard.jsx' import ShardActivity from './routes/public/ShardActivity.jsx' +import ChampSpawns from './routes/public/ChampSpawns.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -36,6 +37,7 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' import ShardAdmin from './routes/admin/views/ShardAdmin.jsx' +import ShardOps from './routes/admin/views/ShardOps.jsx' import AdminCharacters from './routes/admin/views/AdminCharacters.jsx' import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' @@ -77,6 +79,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> {/* CMS pages: top-level /:slug, matched only after the named routes @@ -121,6 +124,14 @@ export default function App() { } /> } /> } /> + + + + } + /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 0ca383e..a5c36a3 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -94,6 +94,7 @@ export const api = { economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`), online: () => req('/public/shard/online'), idoc: () => req('/public/shard/idoc'), + champs: () => req('/public/shard/champs'), }, // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is // fetch-only, so SSE subscribers build the URL from here. The admin stream @@ -259,6 +260,20 @@ export const api = { postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }), deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), + // ----- in-game staff operations: write plane + support queue (admin/moderator) ----- + // `actor` is stamped server-side from the session — never sent from here. + shardOps: { + kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }), + ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }), + unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }), + broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }), + pages: () => req('/admin/shard/pages'), + respondPage: (id, data) => + req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }), + closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }), + audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`), + }, + // ----- Email delivery / Gmail OAuth2 (admin only) ----- getEmailConfig: () => req('/admin/email/config'), saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }), diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx index 34bdb9e..72f247d 100644 --- a/client/src/components/CharacterSheet.jsx +++ b/client/src/components/CharacterSheet.jsx @@ -1,6 +1,12 @@ // Reusable character-sheet renderer for the char.profile shape returned by // /public/shard/char/:serial. Presentational only — the parent handles loading // and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles). +// +// `moderation` opts in the in-game kick/ban controls for the character's account; +// they self-gate to staff (ShardAccountActions), so passing it from a page a +// player can reach is safe. + +import ShardAccountActions from './ShardAccountActions.jsx' const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' } @@ -28,7 +34,7 @@ function Vital({ label, cur, max }) { ) } -export default function CharacterSheet({ char }) { +export default function CharacterSheet({ char, moderation = false }) { if (!char) return null const stats = char.stats || {} const resist = stats.resist || {} @@ -58,6 +64,14 @@ export default function CharacterSheet({ char }) { {char.serial} + {/* Staff moderation for this character's account (self-gates to staff). */} + {moderation && char.acct && ( +
+ Account {char.acct} + +
+ )} + {/* Core stats */}
Attributes
diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx index c4180f8..fccc8b8 100644 --- a/client/src/components/GameAccounts.jsx +++ b/client/src/components/GameAccounts.jsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { Loading, ErrorState } from './PageState.jsx' +import ShardAccountActions from './ShardAccountActions.jsx' // Shared game-account linking + character roster, used by both the player portal // (/player) and the staff account page (/admin/account). `scope` is the api @@ -107,7 +108,7 @@ function AccountRoster({ scope, account, charTo }) { ) } -export default function GameAccounts({ scope, charTo, readOnly = false }) { +export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false }) { const [accounts, setAccounts] = useState(null) const [error, setError] = useState('') @@ -156,6 +157,7 @@ export default function GameAccounts({ scope, charTo, readOnly = false }) {
{a.account}
+ {moderation && }
))} diff --git a/client/src/components/ShardAccountActions.jsx b/client/src/components/ShardAccountActions.jsx new file mode 100644 index 0000000..10510a5 --- /dev/null +++ b/client/src/components/ShardAccountActions.jsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { useAuth } from '../contexts/AuthContext.jsx' +import { api } from '../api/client.js' + +// Compact in-game moderation controls (kick / ban / unban) scoped to a single +// game account. Reused wherever a linked account or character is shown to staff: +// the admin user-detail account list and the character sheet. Self-gates on role +// (admin/moderator) so it is safe to render inside components that players also +// see — a player never gets the controls, and the API enforces the same gate. +// +// `actor` is stamped server-side from the session; nothing here sends it. Kick is +// reversible (they reconnect) so it acts immediately; Ban reveals an inline +// confirm with an optional duration + reason before it fires. +export default function ShardAccountActions({ account, style }) { + const { user } = useAuth() + const [busy, setBusy] = useState('') + const [ok, setOk] = useState('') + const [err, setErr] = useState('') + const [banOpen, setBanOpen] = useState(false) + const [durationSec, setDurationSec] = useState('') + const [reason, setReason] = useState('') + + // Only staff who can actually use the write plane see the controls. + if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null + + async function run(label, fn, done) { + setBusy(label); setOk(''); setErr('') + try { + const r = await fn() + setOk(done(r)) + } catch (e) { + setErr(e.message || 'Action failed.') + } finally { + setBusy('') + } + } + + const kick = () => + run('kick', () => api.admin.shardOps.kick({ account }), (r) => + `Kicked${r && r.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`, + ) + const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.') + const ban = () => + run('ban', () => + api.admin.shardOps.ban({ + account, + durationSec: durationSec === '' ? undefined : Number(durationSec), + reason: reason.trim() || undefined, + }), + () => { + setBanOpen(false) + return `Banned${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.` + }) + + const btn = { fontSize: '0.72rem', padding: '4px 10px' } + + return ( +
+
+ + + + {ok && {ok}} + {err && {err}} +
+ + {banOpen && ( +
+ + + +
+ )} +
+ ) +} diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index e8df12f..65b59aa 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -12,6 +12,7 @@ const NAV = [ { label: 'Newsletter', to: '/site/newsletter' }, { label: 'Wiki', to: '/wiki' }, { label: 'Shard', to: '/site/shard' }, + { label: 'Champions', to: '/site/champs' }, { label: 'About', to: '/site/about' }, ] diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js index 963cd71..00c70ec 100644 --- a/client/src/lib/shardEvents.js +++ b/client/src/lib/shardEvents.js @@ -45,6 +45,24 @@ export function describe(ev) { return 'Shard shut down' case 'server.crashed': return `Shard crashed${p.error ? `: ${p.error}` : ''}` + case 'champ.update': { + const where = p.name || p.type || 'A champion spawn' + if (p.status === 'active' && p.bossUp) return `${where}: boss is up${p.boss ? ` (${p.boss})` : ''}` + if (p.status === 'active') return `${where} is active${p.level != null ? ` — level ${p.level}` : ''}` + if (p.status === 'cooldown') return `${where} is on cooldown` + return `${where} is ${p.status || 'idle'}` + } + case 'champ.remove': + return `A champion spawn ended` + // Support (help-page) queue + in-game moderation (admin channel only) + case 'page.new': + return `New ${p.type || 'help'} page from ${nameOf(p.sender)}` + case 'page.updated': + return `Help page from ${nameOf(p.sender)} updated${p.handled ? ' (claimed)' : ''}` + case 'page.closed': + return `Help page ${p.pageId || ''} closed` + case 'admin.audit': + return `${p.actor || 'Staff'} ${p.action || 'acted'}${p.target ? ` on ${p.target}` : ''}${p.origin ? ` [${p.origin}]` : ''}` // Staff / sensitive (admin channel only) case 'audit.set': return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})` diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 9cce70b..3073b89 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -63,6 +63,7 @@ const NAV = [ title: 'Moderation', items: [ { to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, + { to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] }, ], }, { @@ -94,6 +95,7 @@ const TITLES = { '/admin/wiki': 'Wiki Pages', '/admin/hero': 'Hero Editor', '/admin/moderation': 'Moderation', + '/admin/shard-ops': 'In-Game Ops', '/admin/settings': 'Site Settings', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Web Bot Activity', @@ -136,11 +138,13 @@ export default function AdminLayout() { 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. + // Moderators only get the moderation section (Discord + in-game ops) + their + // own account security. const isModerator = user?.role === 'moderator' + const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/account'] const visible = (item) => { if (item.roles && !item.roles.includes(user?.role)) return false - if (isModerator) return item.to === '/admin/moderation' || item.to === '/admin/account' + if (isModerator) return MOD_PATHS.includes(item.to) return true } // Drop items the current role can't see, then drop any now-empty group so an @@ -178,7 +182,9 @@ export default function AdminLayout() { useEffect(() => { if (!isModerator) return const p = location.pathname - if (!p.startsWith('/admin/moderation') && p !== '/admin/account') { + const allowed = + p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account' + if (!allowed) { navigate('/admin/moderation', { replace: true }) } }, [isModerator, location.pathname, navigate]) diff --git a/client/src/routes/admin/views/AdminCharacter.jsx b/client/src/routes/admin/views/AdminCharacter.jsx index db54934..f320f6a 100644 --- a/client/src/routes/admin/views/AdminCharacter.jsx +++ b/client/src/routes/admin/views/AdminCharacter.jsx @@ -23,7 +23,7 @@ export default function AdminCharacter() { {restarting && } {forbidden && } {error && !restarting && !forbidden && } - {!loading && !error && data && } + {!loading && !error && data && } ) } diff --git a/client/src/routes/admin/views/ShardOps.jsx b/client/src/routes/admin/views/ShardOps.jsx new file mode 100644 index 0000000..9e33dd5 --- /dev/null +++ b/client/src/routes/admin/views/ShardOps.jsx @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useShardFeed } from '../../../lib/useShardFeed.js' +import { describe } from '../../../lib/shardEvents.js' +import { ago } from '../../../lib/format.js' +import { api } from '../../../api/client.js' + +// In-game staff operations: the uo-link write plane (broadcast / kick / ban / +// unban) and the help-page support queue, plus a live audit log. Open to admins +// and moderators. The acting staff member (`actor`) is attached server-side from +// the session — nothing here sends it — so every action is attributable. + +function Flash({ ok, err }) { + if (ok) return {ok} + if (err) return {err} + return null +} + +// ── Broadcast ──────────────────────────────────────────────────────────────── +function Broadcast() { + const [text, setText] = useState('') + const [hue, setHue] = useState('') + const [busy, setBusy] = useState(false) + const [ok, setOk] = useState('') + const [err, setErr] = useState('') + + async function send() { + if (!text.trim()) return setErr('Enter a message.') + setBusy(true); setOk(''); setErr('') + try { + await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) }) + setOk('Broadcast sent.') + setText('') + } catch (e) { + setErr(e.message || 'Could not broadcast.') + } finally { + setBusy(false) + } + } + + return ( +
+

Broadcast

+

+ A system message shown to everyone online right now. +

+ + +
+ + +
+
+ ) +} + +// ── Account actions (kick / ban / unban) ───────────────────────────────────── +function AccountActions() { + const [account, setAccount] = useState('') + const [durationSec, setDurationSec] = useState('') + const [reason, setReason] = useState('') + const [busy, setBusy] = useState('') + const [ok, setOk] = useState('') + const [err, setErr] = useState('') + + const acct = account.trim() + function guard() { + if (!acct) { + setErr('Enter an account name.') + return false + } + return true + } + + async function run(label, fn, done) { + if (!guard()) return + setBusy(label); setOk(''); setErr('') + try { + const r = await fn() + setOk(done(r)) + } catch (e) { + setErr(e.message || 'Action failed.') + } finally { + setBusy('') + } + } + + const kick = () => + run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => `Kicked ${acct}${r?.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`) + const ban = () => + run('ban', () => api.admin.shardOps.ban({ account: acct, durationSec: durationSec === '' ? undefined : Number(durationSec), reason: reason.trim() || undefined }), () => `Banned ${acct}${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`) + const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`) + + return ( +
+

Account actions

+

+ Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner. +

+ +
+ + +
+
+ + + + +
+
+ ) +} + +// ── Support (help-page) queue ──────────────────────────────────────────────── +function PageRow({ page, onDone }) { + const [message, setMessage] = useState('') + const [busy, setBusy] = useState('') + const [err, setErr] = useState('') + + async function respond(close) { + if (!message.trim()) return setErr('Enter a reply first.') + setBusy(close ? 'respond-close' : 'respond'); setErr('') + try { + await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close }) + onDone() + } catch (e) { + setErr(e.message || 'Could not send.') + setBusy('') + } + } + async function close() { + setBusy('close'); setErr('') + try { + await api.admin.shardOps.closePage(page.pageId) + onDone() + } catch (e) { + setErr(e.message || 'Could not close.') + setBusy('') + } + } + + return ( +
+
+
+ {page.type || 'Page'} +
+ {page.sender?.name || page.pageId} + {page.handled && · claimed{page.handler ? ` by ${page.handler}` : ''}} +
+
+ {page.sentMs ? ago(page.sentMs) : ''} +
+ {page.message &&

{page.message}

} +
+ {page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''} +
+