feat(rust): what the site has given a player, as the player reads it
Phase 8's website half. Phase 7 made the site the author of in-game
privilege and gave an operator every view of it; this is the other side,
and it is the first time a player can see what they hold without asking
one.
`GET /player/rust/permissions` is self-scoped in SQL and read-only by
construction — a grant a player could change would not be a grant. Three
things make it a different shape from the admin read rather than a
filtered one:
* the scope arithmetic is answered on the server. A client handed `*`
would have to know what the fleet is to say anything, and then
`inScope` exists twice. Each entry carries the servers it reaches,
already resolved and already marked.
* `live` is the pushed ledger, never the authored row. A grant is not a
privilege in a game until a sync confirmed it, and phase 7 is careful
never to record a push that silently did nothing — so "waiting" is
honest, and the alternative is the site claiming to have given
something it has not.
* nothing says WHY it is waiting. An offline server, a permission no
loaded plugin registered and a store that has never seen the account
all look the same from here; telling them apart is an operator's
diagnosis and an inventory of what is installed.
An entitlement that reaches nobody still lists, and the page says so —
authored against the website account, it exists before a Steam id does,
and hiding it until one turns up is the defect the admin user page
shipped in phase 7 (PLAN.md §20.5).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
@@ -88,6 +88,13 @@ export const playerLinks = {
|
||||
req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
// What the site has given the caller in game (phase 8). Read-only, and beside
|
||||
// `playerLinks` rather than under it: an entitlement exists whether or not an
|
||||
// account is linked yet, which is exactly the state worth showing.
|
||||
export const playerPermissions = {
|
||||
list: () => req('/player/rust/permissions'),
|
||||
}
|
||||
|
||||
// ── admin ─────────────────────────────────────────────────────────────────
|
||||
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
||||
// and a save that omits the field leaves the stored credential alone — so an
|
||||
@@ -215,6 +222,7 @@ export default {
|
||||
servers,
|
||||
playerServers,
|
||||
playerLinks,
|
||||
playerPermissions,
|
||||
admin,
|
||||
adminPermissions,
|
||||
adminConfig,
|
||||
|
||||
@@ -129,6 +129,135 @@ function LinkRow({ link, onRemoved }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
title={server.live ? 'This server has it' : 'This server has not confirmed it yet'}
|
||||
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.6,
|
||||
}}
|
||||
>
|
||||
{server.live ? '● ' : '○ '}
|
||||
{server.name}
|
||||
</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 hollow dot is a server that has not confirmed it yet. One that is offline catches up
|
||||
when it comes back.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -186,6 +315,14 @@ export default function Account() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user