Files
Module-Rust/client/src/routes/player/Account.jsx
wtclaude b89039142e feat(rust): a Steam account linked on the site is a Rust link (D392, D396)
Registers core's MODULE_API 1.17.0 identity hooks (coreApi ^1.17.0):
onIdentityLinking refuses a Steam id that is another website account's Rust
link (D396); onIdentityLinked writes the Rust link with the PRIMARY KEY as the
race guard, reconciles Teams and raises the linked notice, as /link does.
Unlinking Steam leaves the Rust link (D393); the Rust account page says so.

ci/core-ref.json still pins the 1.15.0 core and must move to the website
branch head carrying 1.17.0 before CI can pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-19 14:02:53 -05:00

435 lines
17 KiB
JavaScript

// ── The player's own Rust identity ────────────────────────────────────────
//
// `/player/rust` — where a signed-in player links the Steam account they play
// on. It is the one page in this module a player is asked to *do* something on,
// and the thing they are doing matters more than it looks: from phase 7 the link
// is what in-game permissions are granted against, and from phase 13 it is what
// rewards are handed to.
//
// **A player route renders no layout of its own.** Core wraps `/player/*` in its
// own portal chrome, so this page starts at a heading — unlike the public pages
// in this module, which render `PublicLayout` themselves.
//
// The three-step instruction at the top is not decoration. Nothing else on the
// site tells a player that the code comes from the game, and a code field with no
// explanation is a code field nobody can use.
import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago, coins, shortId } from '../../lib/format.js'
import api from '../../api.js'
import Backpack from '../../components/Backpack.jsx'
/** The code field, and the four answers it can produce. */
function LinkForm({ onLinked }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
async function submit(event) {
event.preventDefault()
if (!code.trim() || busy) return
setBusy(true)
setMessage('')
setError('')
try {
const result = await api.playerLinks.confirm(code.trim())
setMessage(
result.already
? 'That account was already linked to you.'
: `Linked ${result.link.name || shortId(result.link.steamId)}.`,
)
setCode('')
await onLinked()
} catch (err) {
// Every refusal the server sends is already a sentence aimed at a player —
// "run /link again", "run /unlink in game", "try again in a minute" — so
// this renders it rather than replacing it with one of its own. The three
// are not interchangeable, and a page that flattened them into "could not
// link that code" would send a player back to the server that is down.
setError(err.message || 'Could not link that code.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ marginTop: 18 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ display: 'block' }}>
<span className="field-label" style={{ display: 'block', marginBottom: 6 }}>Link code</span>
<input
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="K7M2PQ"
// The plugin's alphabet has no O, 0, I or 1, so a player reading a
// code off their screen cannot produce one — but they can type a
// lowercase one, and the code is matched case-insensitively at the
// other end. Upper-casing here makes what they typed look like what
// they were shown.
maxLength={12}
autoComplete="off"
spellCheck={false}
className="input"
style={{ textTransform: 'uppercase', letterSpacing: '0.18em', width: 160 }}
/>
</label>
<button type="submit" className="btn" disabled={busy || !code.trim()}>
{busy ? 'Checking…' : 'Link account'}
</button>
</div>
{message && (
<p className="sans" style={{ color: '#7fd0a4', fontSize: '0.86rem', margin: '10px 0 0' }}>{message}</p>
)}
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.86rem', margin: '10px 0 0' }}>{error}</p>
)}
</form>
)
}
/** One linked account, and the control that releases it. */
function LinkRow({ link, onRemoved }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function remove() {
setBusy(true)
setError('')
try {
await api.playerLinks.remove(link.steamId)
await onRemoved()
} catch (err) {
setError(err.message || 'Could not unlink that account.')
setBusy(false)
}
}
return (
<li className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
{link.name || shortId(link.steamId)}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{link.steamId} · linked {ago(link.linkedAt)}
{link.serverId ? ` on ${link.serverId}` : ''}
</div>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
)}
</div>
<button type="button" className="btn btn-ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
</li>
)
}
/**
* Where an entitlement has actually landed.
*
* The server resolves the scope and marks each server, so this renders an answer
* rather than working one out — `*` means nothing to a player, and a second
* implementation of the scope arithmetic on the client is a second thing to keep
* true (see `forPlayer` in the permission model).
*/
function Reach({ reach }) {
if (!reach.length) {
return (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
No servers are configured yet
</span>
)
}
return (
<div className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, fontSize: '0.76rem' }}>
{reach.map((server) => (
<span
key={server.id}
style={{
border: '1px solid var(--line, rgba(255,255,255,0.14))',
borderRadius: 999,
padding: '2px 10px',
color: server.live ? 'var(--head)' : undefined,
opacity: server.live ? 1 : 0.65,
}}
>
{/* The word, not only the dot. A filled circle beside a hollow one is
the whole difference between "you have this in game" and "you do
not yet", which is more than a shape should have to carry — and a
reader who cannot tell the two apart gets no answer at all. */}
{server.live ? '● ' : '○ '}
{server.name} · {server.live ? 'has it' : 'waiting'}
</span>
))}
</div>
)
}
/** One group or one direct grant, drawn the same way because they read the same. */
function HeldRow({ title, subtitle, permissions, reach }) {
return (
<li className="panel" style={{ padding: '14px 16px' }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{title}</div>
{subtitle && (
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{subtitle}</div>
)}
{permissions && permissions.length > 0 && (
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
{permissions.join(' · ')}
</div>
)}
<div style={{ marginTop: 10 }}>
<Reach reach={reach} />
</div>
</li>
)
}
/**
* What the site has given this player in game.
*
* Its own read, not part of the links read: an entitlement exists whether or not
* a Steam account is linked, and a player who has just been given something and
* has not linked yet is exactly the person who needs to see both halves at once.
*/
function Held({ accounts }) {
const { data, loading, error } = useAsync(() => api.playerPermissions.list(), [])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const groups = data.groups || []
const grants = data.grants || []
if (!groups.length && !grants.length) {
return (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0, maxWidth: '60ch' }}>
Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on
the servers they cover.
</p>
)
}
const waiting = [...groups, ...grants].some((entry) => entry.reach.some((server) => !server.live))
return (
<>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{groups.map((group) => (
<HeldRow
key={`group:${group.name}`}
title={group.title}
subtitle={`Rank · joined ${ago(group.since)}`}
permissions={group.permissions}
reach={group.reach}
/>
))}
{grants.map((grant) => (
<HeldRow
key={`grant:${grant.permission}:${grant.scope}`}
title={grant.permission}
subtitle={grant.note || `Granted ${ago(grant.since)}`}
reach={grant.reach}
/>
))}
</ul>
{accounts === 0 && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
None of this reaches the game yet — link a Steam account above and the site pushes it
across on its next sync.
</p>
)}
{accounts > 0 && waiting && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
A server marked <em>waiting</em> has not confirmed it yet. One that is offline catches up
when it comes back.
</p>
)}
</>
)
}
/**
* RunicNPC (runicnpc stage 4, D252): your kills of each NPC profile, this wipe,
* by server, across every Steam account you have linked. Nothing without a link.
*/
function NpcKills() {
const { data, loading, error } = useAsync(() => api.playerNpcKills.list(), [])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const rows = (data && data.kills) || []
if (rows.length === 0) {
return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>None this wipe.</p>
}
const byServer = new Map()
for (const r of rows) byServer.set(r.server || r.serverId, [...(byServer.get(r.server || r.serverId) || []), r])
return (
<ul className="sans" style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem', display: 'grid', gap: 6 }}>
{[...byServer.entries()].map(([serverId, list]) => (
<li key={serverId}>
<span className="dim">{serverId}: </span>
{list.map((k, i) => (
<span key={k.profile}>
{i > 0 && <span className="dim"> · </span>}
{k.label} <strong>{k.kills}</strong>
</span>
))}
</li>
))}
</ul>
)
}
/**
* Economics (PLAN_REDESIGNS §11.2.2): your coins on each server, per linked
* account. Live where the server answers; otherwise the last balance it
* reported, marked so. A server without Economics says so (D199) rather than
* vanishing — a player asking "where are my coins" is owed the reason.
*/
function Coins() {
const { data, loading, error } = useAsync(() => api.playerEconomics.list(), [])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const list = (data && data.servers) || []
if (list.length === 0) return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>No servers yet.</p>
return (
<ul className="sans" style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem', display: 'grid', gap: 6 }}>
{list.map((server) => (
<li key={server.serverId}>
<span className="dim">{server.server}: </span>
{!server.economics ? (
<span className="dim">{server.reason}</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 || shortId(a.steamId)} </span>}
<strong>{coins(a.balance)}</strong>
{a.source === 'last-known' && <span className="dim"> (last known — the server did not answer)</span>}
</span>
))
)}
</li>
))}
</ul>
)
}
/**
* Backpacks (PLAN_REDESIGNS §11.3.2): your own backpack, from one of your
* linked accounts, read live when you open it. A server without Backpacks
* says so when asked (D199).
*/
function OwnBackpack({ links }) {
const { data } = useAsync(() => api.playerServers.list(), [])
const list = (data && data.servers) || []
if (!list.length) return null
return (
<Backpack
servers={list.map((s) => ({ id: s.id, name: s.name }))}
accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))}
read={(serverId, steamId) => api.playerBackpacks.read(serverId, steamId)}
/>
)
}
export default function Account() {
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
// the person looking at it changes it, and a page that re-asked every twenty
// seconds would be asking a question nobody is waiting on.
//
// **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and
// that is the whole of its interface — so 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 (see `hooks/usePolled.js`).
const [reloads, setReloads] = useState(0)
const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads])
const links = data ? data.links : []
const reload = useCallback(() => setReloads((n) => n + 1), [])
return (
<div>
<div className="field-label" style={{ marginBottom: 12 }}>Steam accounts</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', maxWidth: '60ch' }}>
Linking tells this site which Steam account is yours, so your play on our servers appears
under your name here — and so rewards and permissions the site hands out can reach you in
game.
</p>
<ol className="sans dim" style={{ fontSize: '0.86rem', marginTop: 14, paddingLeft: 20, maxWidth: '60ch' }}>
<li>Join any of our Rust servers and type <code>/link</code> in chat.</li>
<li>The server replies with a six-character code, only you can see it, and it lasts five minutes.</li>
<li>Type it below. It works once.</li>
</ol>
{/* D392: core's "Link Steam" is a second way to make this link. Unlinking
Steam there removes only the sign-in (D393), so this page says where a
Rust link is removed. */}
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.86rem', maxWidth: '60ch' }}>
If this site offers Steam sign-in, linking Steam on your <a href="/account" className="link-accent">account
page</a> links that Steam account here too. Unlinking it there only stops you signing in with it; remove a
Rust link here.
</p>
<LinkForm onLinked={reload} />
{loading && <Loading />}
{error && <ErrorState error={error} />}
{data && links.length > 0 && (
<ul style={{ listStyle: 'none', margin: '22px 0 0', padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{links.map((link) => (
<LinkRow key={link.steamId} link={link} onRemoved={reload} />
))}
</ul>
)}
{data && links.length > 0 && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 14, maxWidth: '60ch' }}>
A link covers every server this community runs — a Steam account is one person wherever
they play, while stats are kept per server and per wipe. You can also type
{' '}<code>/unlink</code> in game to release one.
</p>
)}
{data && links.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 18 }}>
No Steam account is linked to this profile yet.
</p>
)}
{/* Phase 8. Rendered whether or not anything is linked: an entitlement is
authored against the website account, so it exists before a Steam id
does — and hiding it until one appears is the mistake the admin user
page shipped in phase 7 (PLAN.md §20.5). */}
<div className="field-label" style={{ margin: '30px 0 12px' }}>What you can do in game</div>
{data && <Held accounts={links.length} />}
<div className="field-label" style={{ margin: '30px 0 12px' }}>NPCs you have killed</div>
{data && links.length > 0 ? <NpcKills /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see them.</p>}
<div className="field-label" style={{ margin: '30px 0 12px' }}>Coins</div>
{data && links.length > 0 ? <Coins /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your balance.</p>}
<div className="field-label" style={{ margin: '30px 0 12px' }}>Backpack</div>
{data && links.length > 0 ? <OwnBackpack links={links} /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your backpack.</p>}
</div>
)
}