Files
website/client/src/routes/admin/views/UsersAdmin.jsx
Claude ba4d758eab 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
2026-07-12 09:36:43 -05:00

98 lines
3.5 KiB
JavaScript

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'
import { api } from '../../../api/client.js'
import UserEditor from './UserEditor.jsx'
const ROLE_BADGE = {
admin: 'badge-admin',
editor: 'badge-editor',
moderator: 'badge-moderator',
player: 'badge-player',
}
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])
const [editing, setEditing] = useState(null) // null | 'new' | user
const users = data || []
return (
<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, editor, moderator, and player accounts
</p>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ Add user
</button>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load users." />}
{!loading && !error && (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Username</th>
<th className="adm-th">Role</th>
<th className="adm-th">Status</th>
<th className="adm-th">Last login</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{u.username}
</td>
<td className="adm-td">
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
</td>
<td className="adm-td">
<span
className="sans"
style={{ fontSize: '0.82rem', color: u.status && u.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
>
{u.status || 'active'}
</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' }}>
<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>
))}
</tbody>
</table>
</div>
)}
{editing && (
<UserEditor
user={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
reload()
}}
/>
)}
</section>
)
}