The site half of §11.2.2, on protocol 14:
- `rust.coins.pay` ("Pay coins"): irreversible, reversible none (D335). The
recipient modes of rust.kit.entitle including `linked`, one payment per
website user (D103's rule), an optional message with {amount}, a new
`rust.coins` budget. Refused with "Economics is not installed on this
server" in the dry run too (D199). Core's key goes to the plugin, so a
retried step answers `repeat` and pays nobody twice.
- `rust_player_wipe_stats.balance`: the tally's balance, written as a value.
A nameless balance frame (an offline player) moves nobody's last-seen.
`economics.wiped` clears the wipe's balances, except at a new save.
- The richest: a Coins leaderboard column on one wipe of a server that
reports Economics, `sort=balance`, and a `richest` title category
(Magnate); both rank only above the starting balance (D336).
- GET /player/rust/economics (live, falling back to the last known), and
GET/POST /admin/users/:id/rust/economics for staff — scoped to the user's
own links, keyed per submission, an activity row with before and after.
- Client: the account page's Coins, the admin user page's Coins panel with
its form, the leaderboard column, and Economics in the server's mod line
(saying when its config could not be read).
Server 535/535 (14 new in test/economics.test.js), client 74/74,
routes.manifest regenerated against the pinned core (87 routes), swagger
fragment regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
412 lines
16 KiB
JavaScript
412 lines
16 KiB
JavaScript
// ── 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, useRef, useState } from 'react'
|
|
import { ago, coins, 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 (
|
|
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** One server's all-time totals for this player. */
|
|
function ServerRow({ server }) {
|
|
return (
|
|
<li
|
|
className="sans"
|
|
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.86rem', color: 'var(--ink)' }}
|
|
>
|
|
<span style={{ minWidth: 0, color: 'var(--head)' }}>{server.serverName}</span>
|
|
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
|
{count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)}
|
|
{server.wipes > 1 ? ` · ${server.wipes} wipes` : ''}
|
|
</span>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/** 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 (
|
|
<div className="panel" style={{ padding: '14px 16px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
|
{link.name || link.steamId}
|
|
</div>
|
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
|
{link.steamId} · linked {ago(link.linkedAt)}
|
|
{link.serverId ? ` on ${link.serverId}` : ''}
|
|
{link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'}
|
|
</div>
|
|
{/* 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 && (
|
|
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
|
Linked as “{link.linkedName}”.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button type="button" className="btn btn-ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
|
|
{busy ? 'Unlinking…' : 'Unlink'}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>
|
|
)}
|
|
|
|
{link.servers.length > 0 && (
|
|
<ul
|
|
style={{
|
|
listStyle: 'none',
|
|
margin: '12px 0 0',
|
|
padding: '12px 0 0',
|
|
borderTop: '1px solid var(--line-soft)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 6,
|
|
}}
|
|
>
|
|
{link.servers.map((server) => (
|
|
<ServerRow key={server.serverId} server={server} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Phase 7's half of the panel: what this person may do in game.
|
|
*
|
|
* It renders whenever they hold anything, INCLUDING when they have linked no
|
|
* Steam account — which is the one case worth going out of the way for. A grant
|
|
* against an unlinked person is authored, stored, pushed nowhere, and identical
|
|
* to a working one everywhere except here.
|
|
*/
|
|
function PermissionsPanel({ userId, data, onChanged }) {
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [permission, setPermission] = useState('')
|
|
|
|
const act = async (fn) => {
|
|
setBusy(true)
|
|
setError('')
|
|
try {
|
|
await fn()
|
|
await onChanged()
|
|
} catch (err) {
|
|
setError(err.message || 'That did not work.')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
if (!data) return null
|
|
|
|
const nothing = data.groups.length === 0 && data.grants.length === 0
|
|
|
|
return (
|
|
<div className="panel" style={{ padding: '14px 16px' }}>
|
|
<div className="field-label" style={{ marginBottom: 8 }}>
|
|
Permissions
|
|
</div>
|
|
|
|
{nothing && (
|
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
|
Nothing granted.
|
|
</p>
|
|
)}
|
|
|
|
{data.groups.map((group) => (
|
|
<div key={group.name} className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
|
<span style={{ color: 'var(--head)' }}>{group.title || group.name}</span>{' '}
|
|
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
|
group · {group.scope === '*' ? 'every server' : group.scope}
|
|
{group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
|
|
{data.grants.map((row) => (
|
|
<div
|
|
key={row.id}
|
|
className="sans"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem', padding: '4px 0' }}
|
|
>
|
|
<span style={{ flex: 1, color: 'var(--head)' }}>
|
|
{row.permission}{' '}
|
|
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
|
{row.scope === '*' ? 'every server' : row.scope}
|
|
{row.source !== 'admin' ? ` · ${row.source}` : ''}
|
|
</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
disabled={busy}
|
|
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
|
|
style={{ flex: 'none' }}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
{!nothing && data.reaches.length === 0 && (
|
|
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
|
This account has linked no Steam id, so none of it reaches a game yet. It will apply by
|
|
itself when they link.
|
|
</p>
|
|
)}
|
|
|
|
<form
|
|
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
if (!permission.trim()) return
|
|
act(() =>
|
|
api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }),
|
|
)
|
|
setPermission('')
|
|
}}
|
|
>
|
|
<input
|
|
className="input"
|
|
placeholder="kits.vip"
|
|
value={permission}
|
|
onChange={(event) => setPermission(event.target.value)}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<button type="submit" className="btn" disabled={busy}>
|
|
Grant
|
|
</button>
|
|
</form>
|
|
|
|
{error && (
|
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** One submission's key, so a resent form moves nothing (§11.2.2). */
|
|
function submissionKey() {
|
|
const c = globalThis.crypto
|
|
return c && typeof c.randomUUID === 'function' ? c.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
|
}
|
|
|
|
/** One server's coins for this person, and the form that changes them. */
|
|
function EconomicsServer({ userId, server, onChanged }) {
|
|
const [steamId, setSteamId] = useState(server.accounts.length ? server.accounts[0].steamId : '')
|
|
const [op, setOp] = useState('deposit')
|
|
const [amount, setAmount] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [done, setDone] = useState('')
|
|
|
|
// D199: said, not hidden — staff asking why the form is missing are owed it.
|
|
if (!server.economics) {
|
|
return (
|
|
<div className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
|
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
|
|
<span className="dim" style={{ fontSize: '0.76rem' }}>{server.reason}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
async function submit(event) {
|
|
event.preventDefault()
|
|
if (busy || !steamId || amount === '') return
|
|
setBusy(true)
|
|
setError('')
|
|
setDone('')
|
|
try {
|
|
const result = await api.adminUserEconomics.adjust(userId, { serverId: server.serverId, steamId, op, amount: Number(amount), key: submissionKey() })
|
|
setDone(`${coins(result.before)} → ${coins(result.after)}`)
|
|
setAmount('')
|
|
await onChanged()
|
|
} catch (err) {
|
|
// "They have only 340 coins on Main" is the server's sentence, and the useful one.
|
|
setError(err.message || 'That did not work.')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '6px 0', borderTop: '1px solid var(--line-soft)' }}>
|
|
<div className="sans" style={{ fontSize: '0.84rem' }}>
|
|
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
|
|
{server.accounts.map((a, i) => (
|
|
<span key={a.steamId}>
|
|
{i > 0 && <span className="dim"> · </span>}
|
|
{server.accounts.length > 1 && <span className="dim">{a.name || a.steamId} </span>}
|
|
<strong>{coins(a.balance)}</strong>
|
|
{a.source === 'last-known' && <span className="dim"> (last known)</span>}
|
|
</span>
|
|
))}
|
|
{!server.startingKnown && (
|
|
<span className="dim" style={{ fontSize: '0.74rem' }}>
|
|
{' '}· its Economics config could not be read, so a starting balance of {coins(server.startingBalance)} is assumed
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={submit} style={{ display: 'flex', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
|
|
{server.accounts.length > 1 && (
|
|
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
|
|
{server.accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || a.steamId}</option>)}
|
|
</select>
|
|
)}
|
|
<select className="input" value={op} onChange={(e) => setOp(e.target.value)} aria-label="What to do">
|
|
<option value="deposit">Deposit</option>
|
|
<option value="withdraw">Withdraw</option>
|
|
<option value="set">Set to</option>
|
|
</select>
|
|
<input
|
|
className="input"
|
|
type="number"
|
|
min={op === 'set' ? 0 : 0.01}
|
|
max={1000000}
|
|
step="0.01"
|
|
placeholder="500"
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
aria-label="Coins"
|
|
style={{ width: 120 }}
|
|
/>
|
|
<button type="submit" className="btn" disabled={busy || amount === ''}>
|
|
{busy ? 'Working…' : 'Apply'}
|
|
</button>
|
|
</form>
|
|
|
|
{done && <p className="sans dim" style={{ fontSize: '0.78rem', margin: '6px 0 0' }}>Done: {done}. Recorded in the activity log.</p>}
|
|
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Economics (PLAN_REDESIGNS §11.2.2): this person's coins on every server, and
|
|
* staff's hand on them — the only way coins come back after an event paid them
|
|
* (D335). Its own reload, and it keeps showing the last answer while it
|
|
* re-reads, so a deposit's "1,000 → 1,500" is not blanked by the read it caused.
|
|
*/
|
|
function EconomicsPanel({ userId }) {
|
|
const [reloads, setReloads] = useState(0)
|
|
const { data } = useAsync(() => api.adminUserEconomics.list(userId), [userId, reloads])
|
|
const last = useRef(null)
|
|
if (data) last.current = data
|
|
const shown = data || last.current
|
|
const onChanged = useCallback(() => setReloads((n) => n + 1), [])
|
|
|
|
const list = (shown && shown.servers) || []
|
|
if (!list.length) return null
|
|
|
|
return (
|
|
<div className="panel" style={{ padding: '14px 16px' }}>
|
|
<div className="field-label" style={{ marginBottom: 8 }}>Coins</div>
|
|
{list.map((server) => (
|
|
<EconomicsServer key={server.serverId} userId={userId} server={server} onChanged={onChanged} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 { data: permissions } = useAsync(
|
|
() => api.adminUserPermissions.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.
|
|
// **Both reads decide whether this section exists**, and the second one is the
|
|
// reason. A browser walk found it: a person can hold permissions and have
|
|
// linked no Steam account — which is exactly the state an operator most needs
|
|
// to see, because it is the one that reaches nobody — and a section gated on
|
|
// links alone hides it completely.
|
|
const holdsSomething =
|
|
permissions && (permissions.groups.length > 0 || permissions.grants.length > 0)
|
|
|
|
if (!data || (data.links.length === 0 && !holdsSomething)) return null
|
|
|
|
return (
|
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
|
<SectionTitle>Rust</SectionTitle>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{data.links.map((link) => (
|
|
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
|
|
))}
|
|
|
|
{data.links.length > 0 && (
|
|
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
|
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.
|
|
</p>
|
|
)}
|
|
|
|
{/* Inside the same section rather than beside it: "who is this in game"
|
|
and "what may they do there" are one question asked twice, and an
|
|
operator reading a support ticket has both in front of them. The note
|
|
above belongs to the links, so it sits with them rather than under
|
|
the panel it would otherwise appear to describe. */}
|
|
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
|
|
|
|
{/* Coins belong to a Steam account, so only with one linked. */}
|
|
{data.links.length > 0 && <EconomicsPanel userId={userId} />}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|