feat(admin): view a user's shard footprint at /admin/users/:id

Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.

Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
  so no new routes for roster/vendors/char

Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title

Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
2026-07-12 09:36:43 -05:00
parent 696d82f114
commit ba4d758eab
13 changed files with 586 additions and 32 deletions

View File

@@ -40,6 +40,7 @@ import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.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'
@@ -124,6 +125,7 @@ export default function App() {
<Route path="characters/:serial" element={<AdminCharacter />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="account" element={<AccountAdmin />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>

View File

@@ -159,9 +159,23 @@ export const api = {
botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
listUsers: () => req('/admin/users'),
getUser: (id) => req(`/admin/users/${id}`),
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
// account) so the shared GameAccounts component works unchanged.
userShard: (id) => ({
accounts: () => req(`/admin/users/${id}/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/users/${id}/shard/sales`),
houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`),
}),
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),

View File

@@ -5,7 +5,9 @@ import { Loading, ErrorState } from './PageState.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
// object with { link, accounts, roster } (player or admin self-service); `charTo`
// maps a serial to the route for that character's sheet.
// maps a serial to the route for that character's sheet. `readOnly` drops the
// link forms and self-voice copy for the admin case where staff view *another*
// user's accounts (no `scope.link`) at /admin/users/:id.
function LinkForm({ scope, onLinked, compact }) {
const [code, setCode] = useState('')
@@ -105,7 +107,7 @@ function AccountRoster({ scope, account, charTo }) {
)
}
export default function GameAccounts({ scope, charTo }) {
export default function GameAccounts({ scope, charTo, readOnly = false }) {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
@@ -114,16 +116,26 @@ export default function GameAccounts({ scope, charTo }) {
try {
setAccounts(await scope.accounts())
} catch {
setError('Could not load your game accounts.')
setError(readOnly ? 'Could not load this users game accounts.' : 'Could not load your game accounts.')
}
}, [scope])
}, [scope, readOnly])
useEffect(() => { load() }, [load])
if (error) return <ErrorState message={error} />
if (!accounts) return <Loading />
// Not linked yet — prompt to link.
// No linked accounts. In read-only (admin viewing another user) this is just an
// empty state; otherwise it's the link-your-account prompt.
if (accounts.length === 0) {
if (readOnly) {
return (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
This user has not linked a game account.
</p>
</div>
)
}
return (
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
@@ -147,10 +159,12 @@ export default function GameAccounts({ scope, charTo }) {
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
</section>
))}
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm scope={scope} onLinked={load} compact />
</section>
{!readOnly && (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm scope={scope} onLinked={load} compact />
</section>
)}
</div>
)
}

View File

@@ -129,7 +129,9 @@ export default function AdminLayout() {
? 'Moderation'
: location.pathname.startsWith('/admin/characters')
? 'My Characters'
: 'Admin')
: location.pathname.startsWith('/admin/users/')
? 'User'
: '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)'

View File

@@ -0,0 +1,152 @@
import { useMemo } 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 CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
// Admin read-only view of one user's shard (uo-link) footprint: linked game
// accounts + character rosters, currently-online characters, houses (incl.
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
// Reached from the Users table's "View" action; Edit stays a separate modal.
const ROLE_BADGE = {
admin: 'badge-admin',
editor: 'badge-editor',
moderator: 'badge-moderator',
player: 'badge-player',
}
function SectionTitle({ children }) {
return (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
// Currently-online characters on the user's accounts, with where they are. The
// per-character Online/Offline badge lives in the roster; this adds location.
function OnlineNow({ scope }) {
const { data } = useAsync(() => scope.online(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this users accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<li
key={h.serial}
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{h.stage ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.stage}</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
))}
</ul>
)}
</section>
)
}
function ShardSections({ scope }) {
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly charTo={(serial) => `/admin/characters/${serial}`} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}
export default function UserDetail() {
const { id } = useParams()
// Memoize so the child components' effects (keyed on `scope`) don't refetch
// on every render.
const scope = useMemo(() => api.admin.userShard(id), [id])
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load this user." />
return (
<section>
<Link to="/admin/users" className="link-accent" style={{ fontSize: '0.85rem' }}>
Back to users
</Link>
{/* Header */}
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 24px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
{user.username}
</span>
<span className={`badge ${ROLE_BADGE[user.role] || 'badge-editor'}`}>{user.role}</span>
<span
className="sans"
style={{ fontSize: '0.82rem', color: user.status && user.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
>
{user.status || 'active'}
</span>
</div>
<div className="sans dim" style={{ display: 'flex', gap: 18, marginTop: 10, flexWrap: 'wrap', fontSize: '0.8rem' }}>
{user.email && <span>{user.email}</span>}
<span>Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'}</span>
{user.created_at && <span>Joined: {dateTime(user.created_at)}</span>}
</div>
</div>
<ShardSections scope={scope} />
</section>
)
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime } from '../../../lib/format.js'
@@ -13,6 +14,7 @@ const ROLE_BADGE = {
}
export default function UsersAdmin() {
const navigate = useNavigate()
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
@@ -64,8 +66,13 @@ export default function UsersAdmin() {
</td>
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<span className="link-accent" onClick={() => setEditing(u)}>
Edit
<span style={{ display: 'inline-flex', gap: 16, justifyContent: 'flex-end' }}>
<span className="link-accent" onClick={() => navigate(`/admin/users/${u.id}`)}>
View
</span>
<span className="link-accent" onClick={() => setEditing(u)}>
Edit
</span>
</span>
</td>
</tr>