R1's identity link, site-side, and R13's first extension slot. A player types /link in game, the plugin hands them a six-character code privately, and they enter it here; the site records who owns which Steam account, and an operator sees that on core's own `/admin/users/:id` page. **The site is the author of record and the game holds nothing.** There is no per-account store in Rust that survives a wipe, and phase 7 needs the site authoritative anyway — it pushes permissions INTO the game keyed by Steam id. A copy in the game would be a second thing to reconcile every wipe, for no question it could answer better. ## D24 — a code is minted by ONE server, so every server is asked Nothing in six characters says where it came from. The fleet is asked in turn and the first `link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent at the server that actually holds it. Asking the player to pick was rejected: a wrong pick would come back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous. **"Every reachable server refused" is not the same answer as "a server was unreachable."** Collapsing them tells a player whose server is down that their code is wrong — so they run /link again on that same server and are told the same thing for as long as it stays down. `unsure` is that case, and it says to try again rather than to fetch a new code. ## D23 — a Steam id another account holds is refused, never moved The primary key is `steam_id`, and it is load-bearing rather than tidy: phase 7 grants permissions against a link and phase 13 hangs entitlements off it, so a silent move is an account takeover performed by typing six characters. The refusal names the holder, because the advice is unusable without it. The INSERT is a plain INSERT for the same reason — `ON DUPLICATE KEY UPDATE` here would BE that move — and the duplicate-key error is the refusal for the race the check above cannot close. The way out is `/unlink` in game, which reaches the site off the ingest feed rather than through a route (the plugin has no link to delete). D25 adds the other way out: staff can sever a link from the admin panel, for a player who cannot reach that Steam account in game. ## The slot, and the hole it found in this repo's own generator `admin.users.detail` is declared in `module.json` AND registered in `index.js` AND filled by the chunk — three places, because the server half and the client half are different registrations that share one name. `swaggerFragment.js` knew only about tier routers, so the two routes under `/admin/users/:id` were generated by nothing: a fragment that was internally consistent and described two routes fewer than the module serves. A slot's mount is core's and cannot be derived here, so it is a fourth constant beside `TIER_BASE` — held to account by the frozen-manifest job, which was verified to catch exactly this by removing the two paths and watching it fail. ## Smaller things worth knowing - **Core's `useAsync` has no `refresh`.** A counter in the deps is how a page re-reads after its own write; it blanks while it re-reads, which is right here and is exactly what made it wrong for a poll. - **Every player-portal nav row needs an `icon`** — core draws one on every row, and the client suite says so. This module had no icons file until now, because the public header is text buttons. - The two new frame kinds are STAFF-only. Neither carries a code, but both name a Steam id beside a website account's activity, and that join is not a public fact about what happened on a server. - The link code route carries its own rate limiter rather than core's `accountChangeLimiter`: this is guessing somebody else's secret, not changing your own password, and a shared counter would let one policy set the other. Protocol 3 on all three declaration sites; 17 new tests, 136 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
148 lines
5.9 KiB
JavaScript
148 lines
5.9 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, 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 (
|
|
<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 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>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<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} />
|
|
))}
|
|
</div>
|
|
|
|
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '12px 0 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>
|
|
</section>
|
|
)
|
|
}
|