// ── This module's fill for `admin.users.detail` ───────────────────────────
//
// R13's first slot, and the phase criterion as an operator meets it: the Steam
// id inside core's own user page, under core's own security panel.
//
// **The slot hands over `userId` and nothing else** — not a client. So this file
// builds its own bindings for the routes the server half registered
// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of
// a call belong to the same module even when the URL between them is core's.
//
// **Most users have no Rust account, so most of the time this renders nothing.**
// A panel that announced "no linked Steam accounts" on every user page in a
// community that also runs a UO shard would be noise on the overwhelming
// majority of them. Silence is the honest answer to "what does the Rust module
// know about this person" when it is nothing.
import { useCallback, useState } from 'react'
import { ago, count, duration } from '../../lib/format.js'
import { useAsync } from '../../core.js'
import api from '../../api.js'
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
function SectionTitle({ children }) {
return (
{children}
)
}
/** One server's all-time totals for this player. */
function ServerRow({ server }) {
return (
)
}
/** One linked Steam account: who it is, when it was linked, and the way out. */
function LinkPanel({ userId, link, onRemoved }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function unlink() {
setBusy(true)
setError('')
try {
await api.adminUserLinks.remove(userId, link.steamId)
await onRemoved()
} catch (err) {
setError(err.message || 'Could not unlink that account.')
setBusy(false)
}
}
return (
{link.name || link.steamId}
{link.steamId} · linked {ago(link.linkedAt)}
{link.serverId ? ` on ${link.serverId}` : ''}
{link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'}
{/* Worth showing only when they differ: the name on the link is what
they were called when they linked, the other is what the game last
saw. A rename is the ordinary reason, and an operator reading a
support ticket wants both names. */}
{link.linkedName && link.name && link.linkedName !== link.name && (
Linked as “{link.linkedName}”.
)}
{error && (
{error}
)}
{link.servers.length > 0 && (
{link.servers.map((server) => (
))}
)}
)
}
export default function UserRustSections({ userId }) {
// Core's `useAsync` has no refresh, so a counter in the deps is how this
// re-reads after its own write (the same shape the player page uses).
const [reloads, setReloads] = useState(0)
const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads])
const reload = useCallback(() => setReloads((n) => n + 1), [])
// No `Loading` and no `ErrorState`, deliberately. This is a section inside
// somebody else's page: a spinner on every user page for a module most users
// have nothing to do with is worse than a section that appears when it has
// something, and a failure here must not replace core's own user detail with an
// error card.
if (!data || data.links.length === 0) return null
return (
Rust
{data.links.map((link) => (
))}
A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
recorded in the activity log — it is the way back for a player who linked the wrong account
and cannot reach it in game.