feat(rust): site-owned permissions — the site is the author, the game is the cache

R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: a name in the store that is not in the
desired set is either something the site retired or something a human granted, and
those two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-21 18:28:32 -05:00
parent a1b6d155a1
commit 43147b796a
27 changed files with 5515 additions and 21 deletions

View File

@@ -103,6 +103,50 @@ export const admin = {
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
}
// ── admin · permissions (R2) ──────────────────────────────────────────────
//
// The authoring surface. Every call here writes to the SITE, and none of them
// reaches a game server — the mirror's own loop does that on its own cadence.
// `sync` is the exception and says so in its name: it runs the pass now and
// answers with what each server reported, which is the only call on this screen
// that can be slow or fail because a game host is down.
//
// A write is followed by a re-read rather than a local edit of the model: what
// the screen is showing is partly the game's answer, and the honest way to learn
// the new one is to ask.
export const adminPermissions = {
overview: () => req('/admin/rust/permissions'),
catalogue: () => req('/admin/rust/permissions/catalogue'),
saveGroup: (name, body) =>
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }),
deleteGroup: (name) =>
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }),
addMember: (name, username) =>
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, {
method: 'POST',
body: { username },
}),
removeMember: (name, userId) =>
req(
`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`,
{ method: 'DELETE' },
),
grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }),
revoke: (id) =>
req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }),
adoptDrift: (id) =>
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }),
revokeDrift: (id) =>
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
sync: (serverId = null) =>
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
}
// ── the admin.users.detail extension slot ─────────────────────────────────
//
// The client half of R13's first slot. Core hands the component a `userId` and
@@ -118,8 +162,34 @@ export const adminUserLinks = {
}),
}
// The same panel's phase 7 half: what this person may do in game. The id in the
// path is the one the slot handed the component, so these send `userId` rather
// than a name — the screen already knows who it is looking at.
export const adminUserPermissions = {
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`),
grant: (userId, body) =>
req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, {
method: 'POST',
body,
}),
revoke: (userId, grantId) =>
req(
`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`,
{ method: 'DELETE' },
),
}
// Exported for the rare caller that needs the base itself — an `<img src>`, a
// download link, an EventSource. Reach for `request` first.
export { BASE, query }
export default { servers, playerServers, playerLinks, admin, adminUserLinks, BASE }
export default {
servers,
playerServers,
playerLinks,
admin,
adminPermissions,
adminUserLinks,
adminUserPermissions,
BASE,
}

View File

@@ -21,9 +21,10 @@ import { registry, coreApiVersion } from './core.js'
import Servers from './routes/public/Servers.jsx'
import ServerDetail from './routes/public/ServerDetail.jsx'
import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
import { IconLink } from './icons.jsx'
import { IconKey, IconLink } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -63,12 +64,25 @@ const ID = 'rust'
// to do, and a landing page above one page is a page nobody wants. Core applies
// its own portal chrome and its own auth gate to the tier, so the component
// renders no layout and re-implements no check.
//
// **The admin route arrives in phase 7 and is this module's first.** Everything
// before it was configured through the API — the server rows still are — because
// nothing until now had to be AUTHORED. A permission model is different in kind:
// it is a thing an operator composes and keeps looking at, and there is no
// version of "grant somebody VIP" that belongs in a terminal.
//
// It is registered with an empty path, so it lands at `/admin/rust`, and core
// applies the admin tier's own gate. The routes underneath it are stricter than
// that gate (`requireRole('admin')` on every one), which is a server-side answer
// rather than a client one: a moderator who reached this page would see it fail
// honestly rather than be quietly shown a page that cannot save.
registry.registerRoutes(ID, {
public: [
{ path: '', element: <Servers /> },
{ path: 'servers/:id', element: <ServerDetail /> },
],
player: [{ path: '', element: <Account /> }],
admin: [{ path: '', element: <Permissions /> }],
})
// ── Nav ───────────────────────────────────────────────────────────────────
@@ -105,6 +119,17 @@ registry.registerNav(ID, {
items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }],
})
// The admin sidebar's row. `group` names an existing core group — an unknown name
// appends a new group at the end rather than dropping the row, which is the
// failure mode to avoid here: a row nobody can find is a feature nobody has.
//
// It carries an icon for the same reason the player row does: core draws one on
// every sidebar row, and the one without is the only text in a column of glyphs.
registry.registerNav(ID, {
area: 'admin',
items: [{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey }],
})
// ── Extension slots ───────────────────────────────────────────────────────
//
// Core declares a slot, only core may declare one, and at most one module may

View File

@@ -46,4 +46,20 @@ export const IconLink = () => (
</Icon>
)
export default { IconLink }
/**
* A key — the admin sidebar's row for the permission mirror.
*
* Core's admin groups are labelled by subject and drawn with glyphs of the same
* weight, so this is the same 16px frame as the portal's. A key rather than a
* shield: a shield is protection from something, and this row is about handing
* somebody the right to do something.
*/
export const IconKey = () => (
<Icon>
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="M10.7 12.3L20 3" />
<path d="M17 6l2.5 2.5" />
</Icon>
)
export default { IconLink, IconKey }

View File

@@ -0,0 +1,617 @@
// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// R2's authoring surface, and this module's first admin page.
//
// **What is on it is decided by what an operator can get wrong**, rather than by
// what the tables contain. Four states are invisible from the game and from a
// list of grants, and every one of them looks exactly like success:
//
// • a grant against somebody who has linked no Steam account — authored,
// stored, pushed nowhere;
// • a permission no loaded plugin has registered — the grant lands silently
// nowhere, because `GrantUserPermission` no-ops for an unregistered name;
// • a group member who has never connected — the store has no user record to
// put in a group yet, and the membership waits for their first connection;
// • a server whose last sync failed — the site is authoritative and the game
// has not heard it.
//
// So each of those is a sentence on this page rather than a number in a report.
//
// The screen never writes to a game. Every button here writes to the site and
// the mirror's loop reconciles within seconds — except *Sync now*, which runs
// that pass immediately because an operator who has just changed something
// should not have to trust a timer to find out that a host is unreachable.
import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
const FLEET = '*'
/** Shared furniture. The kit is nine exports and none of them is a table. */
function Card({ title, subtitle, children, actions }) {
return (
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{title}
</h2>
{subtitle && (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{subtitle}
</span>
)}
<span style={{ flex: 1 }} />
{actions}
</header>
{children}
</section>
)
}
function Row({ children, muted = false }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 0',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.86rem',
color: muted ? 'var(--ink)' : 'var(--head)',
}}
>
{children}
</div>
)
}
function Warn({ children }) {
return (
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>
{children}
</p>
)
}
function Scope({ value }) {
return (
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{value === FLEET ? 'every server' : value}
</span>
)
}
/**
* One server's mirror state.
*
* `unresolved` and `pending` are rendered as sentences rather than counts
* because each is a different problem with a different fix, and both are
* invisible everywhere else on this page.
*/
function ServerState({ row, onSync, busy }) {
const report = row.report || {}
const unresolved = report.unresolved || []
const pending = report.pending || []
return (
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{row.serverId}
</span>
<span
className="sans"
style={{ fontSize: '0.76rem', color: row.inSync ? 'var(--ink)' : '#d08a2a' }}
>
{row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'}
</span>
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
</span>
<span style={{ flex: 1 }} />
<button type="button" className="btn ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
{busy ? 'Syncing…' : 'Sync now'}
</button>
</div>
{row.error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.78rem', margin: '4px 0 0' }}>
{row.error}
</p>
)}
{unresolved.length > 0 && (
<Warn>
{unresolved.join(', ')} — no plugin loaded on this server has registered{' '}
{unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '}
{unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when
the plugin is back.
</Warn>
)}
{pending.length > 0 && (
<Warn>
{pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a
first connection — this server has never seen those players, so it has no account to put
in a group yet.
</Warn>
)}
</div>
)
}
/** A hand edit, with the two answers to it. */
function DriftRow({ row, onAdopt, onRevoke, busy }) {
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
return (
<Row>
<span style={{ minWidth: 0, flex: 1 }}>
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
<span className="dim" style={{ fontSize: '0.78rem' }}>
{row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
{row.serverId} · seen {ago(row.firstSeen)}
</span>
</span>
<button type="button" className="btn ghost" onClick={() => onAdopt(row)} disabled={busy}>
Adopt
</button>
<button type="button" className="btn ghost" onClick={() => onRevoke(row)} disabled={busy}>
Revoke
</button>
</Row>
)
}
/**
* The memberships the game could not place yet, as `steamId:group`.
*
* Read out of each server's own report, because it is the only thing that knows:
* a member who has never connected to a server has no user record there to put
* in a group (§12.2 rule 4), and from every other angle they look like a member.
* The server strip says how many; this is what puts it next to the person.
*/
function pendingSet(servers) {
const pending = new Map()
for (const server of servers) {
for (const entry of (server.report && server.report.pending) || []) {
if (!pending.has(entry)) pending.set(entry, [])
pending.get(entry).push(server.serverId)
}
}
return pending
}
function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
const [busy, setBusy] = useState(false)
const [member, setMember] = 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)
}
}
const save = (permissions) =>
act(() =>
api.adminPermissions.saveGroup(group.name, {
title: group.title,
rank: group.rank,
scope: group.scope,
permissions,
}),
)
return (
<Card
title={group.title || group.name}
subtitle={<>{group.name} · <Scope value={group.scope} /></>}
actions={
<button
type="button"
className="btn ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
>
Delete
</button>
}
>
<div className="field-label">Permissions</div>
{group.permissions.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
This group carries nothing, so being in it does nothing.
</p>
)}
{group.permissions.map((perm) => (
<Row key={perm}>
<span style={{ flex: 1 }}>{perm}</span>
{!catalogue.some((entry) => entry.permission === perm) && (
<span className="sans" style={{ color: '#d08a2a', fontSize: '0.74rem' }}>
no server has registered this
</span>
)}
<button
type="button"
className="btn ghost"
disabled={busy}
onClick={() => save(group.permissions.filter((p) => p !== perm))}
>
Remove
</button>
</Row>
))}
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(event) => {
event.preventDefault()
if (!permission.trim()) return
save([...group.permissions, permission.trim().toLowerCase()])
setPermission('')
}}
>
<input
list="rust-permission-names"
className="input"
placeholder="kits.vip"
value={permission}
onChange={(event) => setPermission(event.target.value)}
style={{ flex: 1 }}
/>
<button type="submit" className="btn" disabled={busy}>
Add permission
</button>
</form>
<div className="field-label" style={{ marginTop: 18 }}>
Members
</div>
{group.members.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
Nobody is in this group.
</p>
)}
{group.members.map((m) => {
const waiting = m.accounts
.map((account) => pending.get(`${account.steamId}:${group.name}`))
.filter(Boolean)
.flat()
return (
<Row key={m.userId}>
<span style={{ flex: 1 }}>
{m.username}
{m.accounts.length > 0 ? (
<span className="dim" style={{ fontSize: '0.76rem' }}>
{' '}
· {m.accounts.map((a) => a.name || a.steamId).join(', ')}
</span>
) : (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· has linked no Steam account, so this reaches nobody
</span>
)}
{waiting.length > 0 && (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· waiting on their first connection to {[...new Set(waiting)].join(', ')}
</span>
)}
</span>
<button
type="button"
className="btn ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
>
Remove
</button>
</Row>
)
})}
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(event) => {
event.preventDefault()
if (!member.trim()) return
act(() => api.adminPermissions.addMember(group.name, member.trim()))
setMember('')
}}
>
<input
className="input"
placeholder="website username"
value={member}
onChange={(event) => setMember(event.target.value)}
style={{ flex: 1 }}
/>
<button type="submit" className="btn" disabled={busy}>
Add member
</button>
</form>
{servers.length > 1 && group.scope !== FLEET && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
This group exists on {group.scope} only. The other servers never receive it.
</p>
)}
</Card>
)
}
export default function Permissions() {
const [reloads, setReloads] = useState(0)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [form, setForm] = useState({ name: '', title: '', scope: FLEET })
const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET })
const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads])
const reload = useCallback(() => setReloads((n) => n + 1), [])
const act = async (fn) => {
setBusy(true)
setError('')
try {
await fn()
reload()
} catch (err) {
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
return (
<div style={{ maxWidth: 900 }}>
{/* No heading of our own: core's admin chrome already draws the route's
title above the page, and a second one is the same words twice. */}
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
This site is the author of record. Groups and grants written here are pushed into each
server’s own permission store, so every plugin that checks a permission honours them — and a
wipe does not lose them, because they are re-pushed when the server comes back.
</p>
{/* The option source, shared by both forms. A datalist rather than a select:
a name that no server has registered is still authorable — the plugin
may simply not be loaded right now — and the warning beside it is the
honest treatment, where a closed list would be a refusal. */}
<datalist id="rust-permission-names">
{(data.catalogue || []).map((entry) => (
<option key={entry.permission} value={entry.permission} />
))}
</datalist>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>
{error}
</p>
)}
<Card
title="Servers"
subtitle={`${servers.length} configured`}
actions={
<button type="button" className="btn ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
Sync all
</button>
}
>
{servers.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
No servers are configured yet, so nothing written here reaches a game.
</p>
)}
{servers.map((row) => (
<ServerState
key={row.serverId}
row={row}
busy={busy}
onSync={(id) => act(() => api.adminPermissions.sync(id))}
/>
))}
</Card>
{(data.drift || []).length > 0 && (
<Card
title="Changed in game"
subtitle="granted at a console, not by this site"
>
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
Nothing here is undone automatically. <strong>Adopt</strong> records it as the site’s
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
the next sync.
</p>
{data.drift.map((row) => (
<DriftRow
key={row.id}
row={row}
busy={busy}
onAdopt={(d) => act(() => api.adminPermissions.adoptDrift(d.id))}
onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))}
/>
))}
</Card>
)}
<Card title="Direct grants" subtitle="one person, one permission">
{(data.grants || []).length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
Nobody holds a permission of their own yet.
</p>
)}
{(data.grants || []).map((row) => (
<Row key={row.id}>
<span style={{ flex: 1 }}>
{row.username} · <strong style={{ fontWeight: 500 }}>{row.permission}</strong>{' '}
<Scope value={row.scope} />
{row.accounts.length === 0 && (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· has linked no Steam account, so this reaches nobody
</span>
)}
{/* The same warning the group's permission list carries, and it
matters more here: a grant naming a permission nothing has
registered is the failure the plugin's pre-check exists for,
and it is invisible on this row without it. */}
{!(data.catalogue || []).some((entry) => entry.permission === row.permission) && (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· no server has registered this permission
</span>
)}
{row.source !== 'admin' && (
<span className="dim" style={{ fontSize: '0.74rem' }}> · {row.source}</span>
)}
</span>
<button
type="button"
className="btn ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
>
Remove
</button>
</Row>
))}
<form
style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}
onSubmit={(event) => {
event.preventDefault()
if (!grant.username.trim() || !grant.permission.trim()) return
act(() =>
api.adminPermissions.grant({
username: grant.username.trim(),
permission: grant.permission.trim().toLowerCase(),
scope: grant.scope,
}),
)
setGrant({ username: '', permission: '', scope: FLEET })
}}
>
<input
className="input"
placeholder="website username"
value={grant.username}
onChange={(event) => setGrant({ ...grant, username: event.target.value })}
style={{ flex: '1 1 160px' }}
/>
<input
list="rust-permission-names"
className="input"
placeholder="kits.vip"
value={grant.permission}
onChange={(event) => setGrant({ ...grant, permission: event.target.value })}
style={{ flex: '1 1 160px' }}
/>
<select
className="input"
value={grant.scope}
onChange={(event) => setGrant({ ...grant, scope: event.target.value })}
>
<option value={FLEET}>every server</option>
{servers.map((row) => (
<option key={row.serverId} value={row.serverId}>
{row.serverId}
</option>
))}
</select>
<button type="submit" className="btn" disabled={busy}>
Grant
</button>
</form>
</Card>
{(data.groups || []).map((group) => (
<GroupCard
key={group.name}
group={group}
catalogue={data.catalogue || []}
servers={servers}
pending={pendingSet(servers)}
onChanged={reload}
setError={setError}
/>
))}
<Card title="New group">
<form
style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}
onSubmit={(event) => {
event.preventDefault()
if (!form.name.trim()) return
act(() =>
api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), {
title: form.title.trim() || form.name.trim(),
scope: form.scope,
permissions: [],
}),
)
setForm({ name: '', title: '', scope: FLEET })
}}
>
<input
className="input"
placeholder="vip"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
style={{ flex: '1 1 140px' }}
/>
<input
className="input"
placeholder="VIP"
value={form.title}
onChange={(event) => setForm({ ...form, title: event.target.value })}
style={{ flex: '1 1 140px' }}
/>
<select
className="input"
value={form.scope}
onChange={(event) => setForm({ ...form, scope: event.target.value })}
>
<option value={FLEET}>every server</option>
{servers.map((row) => (
<option key={row.serverId} value={row.serverId}>
{row.serverId}
</option>
))}
</select>
<button type="submit" className="btn" disabled={busy}>
Create
</button>
</form>
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
A group is created in each in-scope game as a real group, so plugins that read group
membership see it. A member who has never connected to a server joins it there on their
first connection — a direct grant reaches them straight away, which is the difference
worth knowing when somebody is waiting.
</p>
</Card>
</div>
)
}

View File

@@ -113,11 +113,131 @@ function LinkPanel({ userId, link, onRemoved }) {
)
}
/**
* 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 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>
)
}
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
@@ -125,7 +245,15 @@ export default function UserRustSections({ userId }) {
// 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
// **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 }}>
@@ -135,13 +263,22 @@ export default function UserRustSections({ userId }) {
{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>
{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} />
</div>
</section>
)
}