Restrict public presence to staff + let admins view any character

Public "Online now" now lists only players whose game account is linked
to a STAFF website user (admin/editor/moderator) — linked players are no
longer exposed publicly with their name and location. listOnlineLinked
joins through to users and filters on role; the section is relabeled
"Staff online".

Character/roster/vendor reads gain an admin bypass: admins may view any
character's data, while players (and editor/moderator staff) stay limited
to accounts they have personally linked. The bypass lives in the shared
player controller and only ever widens access for genuine admins.

Also finalizes the uo-link character/vendor front end (player + admin
character sheets, VendorSales component, ShardChar removed) and
regenerates swagger-output.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
This commit is contained in:
2026-07-11 09:15:18 -05:00
parent 49d0c1bd11
commit c4245e3f6a
20 changed files with 643 additions and 216 deletions

View File

@@ -17,7 +17,6 @@ import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardChar from './routes/public/ShardChar.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
@@ -77,7 +76,6 @@ export default function App() {
<Route path="/site/status" element={<Status />} />
<Route path="/site/shard" element={<Shard />} />
<Route path="/site/shard/activity" element={<ShardActivity />} />
<Route path="/site/shard/char/:serial" element={<ShardChar />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* CMS pages: top-level /:slug, matched only after the named routes

View File

@@ -94,7 +94,6 @@ export const api = {
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
},
// 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
@@ -222,6 +221,8 @@ export const api = {
accounts: () => req('/admin/shard/accounts'),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/admin/shard/sales'),
},
// ----- auth providers / SSO config (admin only) -----
@@ -269,6 +270,8 @@ export const api = {
accounts: () => req('/player/shard/accounts'),
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/player/shard/sales'),
},
},
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import { ago } from '../lib/format.js'
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
// sales for accounts linked to the caller.
export default function VendorSales({ fetchSales }) {
const [sales, setSales] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let active = true
fetchSales()
.then((rows) => active && setSales(rows))
.catch(() => active && setError('Could not load your vendor sales.'))
return () => { active = false }
}, [fetchSales])
if (error) return null
if (!sales) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
{sales.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{sales.map((s, i) => (
<li key={`${s.t}-${i}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} {Number(s.price || 0).toLocaleString()}gp
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -62,9 +62,11 @@ export function describe(ev) {
}
// Category grouping for the filter tabs.
// Vendor sales are intentionally NOT a public category — they are owner-private
// (a linked player sees their own under the portal). The admin live feed still
// describes vendor.sale via describe() below.
export const CATEGORIES = [
{ id: 'all', label: 'All', kinds: null },
{ id: 'sales', label: 'Vendor sales', kinds: ['vendor.sale'] },
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },

View File

@@ -4,12 +4,13 @@ import CharacterSheet from '../../../components/CharacterSheet.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { api } from '../../../api/client.js'
// A staff member's character sheet inside the admin shell. Character data is
// public MMO data, so it uses the same cached public endpoint.
// A staff member's own character sheet inside the admin shell. Owner-checked —
// the endpoint only returns a sheet for a character on the caller's linked account.
export default function AdminCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div style={{ maxWidth: 760 }}>
@@ -20,7 +21,8 @@ export default function AdminCharacter() {
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{error && !restarting && <ErrorState message="Could not load that character right now." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />}
</div>
)

View File

@@ -1,4 +1,5 @@
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
import { api } from '../../../api/client.js'
// Staff link their OWN in-game account and view their characters — the same
@@ -10,6 +11,7 @@ export default function AdminCharacters() {
Link your own game account to view your characters, stats, skills and vendors.
</p>
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
<VendorSales fetchSales={api.admin.shard.sales} />
</section>
)
}

View File

@@ -4,12 +4,13 @@ import CharacterSheet from '../../components/CharacterSheet.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// A player's character sheet inside the portal. Character data is public MMO
// data, so it uses the same cached public endpoint the site does.
// A player's character sheet inside the portal. Owner-checked: the endpoint only
// returns a sheet for a character on an account linked to the caller.
export default function PlayerCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div>
@@ -20,7 +21,8 @@ export default function PlayerCharacter() {
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{error && !restarting && <ErrorState message="Could not load that character right now." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />}
</div>
)

View File

@@ -1,13 +1,16 @@
import GameAccounts from '../../components/GameAccounts.jsx'
import VendorSales from '../../components/VendorSales.jsx'
import { api } from '../../api/client.js'
// The logged-in player's characters. Shows the link prompt when no game account
// is linked, otherwise their characters grouped by account (shared component).
// is linked, otherwise their characters grouped by account (shared component),
// plus their own recent vendor sales.
export default function PlayerCharacters() {
return (
<div>
<h1 className="display" style={{ margin: '0 0 18px', fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
<VendorSales fetchSales={api.player.shard.sales} />
</div>
)
}

View File

@@ -47,11 +47,10 @@ export default function Shard() {
const { loading, error, data } = useAsync(() =>
Promise.all([
api.shard.status(),
api.shard.feed({ kind: 'vendor.sale', limit: 8 }),
api.shard.idoc(),
api.shard.economy(60),
api.shard.online(),
]).then(([status, sales, idoc, economy, online]) => ({ status, sales, idoc, economy, online })),
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
)
const { events, connected } = useShardFeed({ max: 30 })
@@ -115,26 +114,25 @@ export default function Shard() {
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
</section>
{/* Online now */}
{/* Staff online — linked staff accounts only, with location */}
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
Online now
Staff online
</div>
{(!data.online || data.online.length === 0) ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No one is online right now.</p>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.online.map((p) => (
<Link
key={p.serial}
to={`/site/shard/char/${p.serial}`}
className="sans"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px', border: '1px solid var(--line)', borderRadius: 999, color: 'var(--ink)', textDecoration: 'none' }}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
{p.name || p.serial}
{p.map && <span className="dim" style={{ fontSize: '0.76rem' }}>· {p.map}</span>}
</Link>
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
{p.name || p.serial}
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
</span>
</div>
))}
</div>
)}
@@ -150,13 +148,7 @@ export default function Shard() {
</section>
)}
<div className="grid-2" style={{ gap: 18, marginBottom: 24 }}>
{/* Recent vendor sales */}
<FeedList
title="Recent vendor sales"
empty="No sales recorded yet."
items={data.sales.map((s) => ({ id: s.id, text: describe(s), when: s.t }))}
/>
<div style={{ marginBottom: 24 }}>
{/* Latest IDOC */}
<FeedList
title="Houses in danger (IDOC)"

View File

@@ -1,37 +0,0 @@
import { useParams, Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import CharacterSheet from '../../components/CharacterSheet.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// Public character viewer: /site/shard/char/:serial. Renders the live sheet from
// the sidecar (cached server-side). A 503 means the shard is restarting.
export default function ShardChar() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
const restarting = error && error.status === 503
const notFound = error && error.status === 404
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Character" title={data?.name || 'Character'} />
<p style={{ marginTop: -8, marginBottom: 20 }}>
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
Back to shard
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{notFound && <ErrorState message="No character with that serial." />}
{error && !restarting && !notFound && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />}
</div>
</PublicLayout>
)
}