import { useCallback, useEffect, useMemo, useState } 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 (
{children}
)
}
// 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 (
Online now
{data.length === 0 ? (
No characters online right now.
) : (
{data.map((c) => (
{c.name || '(unnamed)'}
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
))}
)}
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
Standing
{govs.map((g) => (
Governor of {g.city}
))}
{guilds.map((g) => (
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
))}
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
{h.name || 'Unnamed house'}
{h.isIdoc && IDOC }
{location}
{coords}
{owner}
{shares}
{(h.decay || h.stage) ?
{h.decay || h.stage}
: null}
{h.price != null ?
{Number(h.price).toLocaleString()} gp
: null}
{h.lastRefreshed ?
refreshed {ago(h.lastRefreshed)}
: null}
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
Houses
{data.length === 0 ? (
No houses recorded for this user’s accounts.
) : (
)}
)
}
// Admin security controls for one user: their trusted devices (view + revoke) and
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
try {
setDevices(await api.admin.userTrustedDevices(userId))
} catch {
setError('Could not load trusted devices.')
}
}, [userId])
useEffect(() => {
load()
}, [load])
async function revoke(deviceId) {
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeUserTrustedDevice(userId, deviceId)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Revoke ALL of this user’s trusted devices?')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeAllUserTrustedDevices(userId)
setMsg('All trusted devices revoked.')
await load()
} catch {
setError('Could not revoke devices.')
} finally {
setBusy(false)
}
}
async function resetMfa() {
if (!window.confirm('Reset this user’s two-factor? This turns TOTP off, revokes their trusted devices, and clears their recovery codes so they can sign in with their password.')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.resetUserMfa(userId)
setMsg('Two-factor has been reset for this user.')
await load()
} catch {
setError('Could not reset two-factor.')
} finally {
setBusy(false)
}
}
const fmt = (d) => {
const t = d ? new Date(d) : null
return t && !Number.isNaN(t.getTime()) ? t.toLocaleDateString() : '—'
}
return (
Security & two-factor
{devices == null ? (
Loading…
) : devices.length === 0 ? (
No trusted devices.
) : (
{devices.map((d) => (
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
{d.userAgent || '—'} · last used {fmt(d.lastUsedAt)} · expires {fmt(d.expiresAt)}
revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
))}
)}
{devices && devices.length > 0 && (
Revoke all trusted devices
)}
Reset two-factor
{msg && {msg}
}
{error && {error}
}
)
}
function ShardSections({ scope }) {
return (
<>
Linked accounts & characters
`/admin/characters/${serial}`} />
>
)
}
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
if (error) return
return (
← Back to users
{/* Header */}
{user.username}
{user.role}
{user.status || 'active'}
{user.email && {user.email} }
Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'}
{user.created_at && Joined: {dateTime(user.created_at)} }
)
}