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.

) : ( )}
) } // 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 && devices.length > 0 && ( )}
    {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)}}
    ) }