Files
Module-Rust/client/src/routes/admin/Permissions.jsx
wtclaude a3bcec9cde feat(rust): the world verbs, their budgets and the reconcile watch (phase 13a, protocol 9)
- registerEventActions: rust.zone.open and rust.prefab.place, both
  reversible 'ledger' with revert() and reconcile(), budgetMs 15000 above the
  client's 12 s. A location is a monument (kind + instance, carrying its
  server) or raw coordinates, exactly one (D87, D93); bounds mirrored from the
  plugin so a bad step is refused on the form (D95); zone minutes required and
  held by the game (D96).
- registerEventBudgets: rust.prefabs, rust.npcs and rust.zone.minutes, each
  beside the verb that spends it (D79, D89).
- Option sources rust.options.monuments (live, searchable) and
  rust.options.prefabs (mirrored, answers with every server off), registered in
  the one batch core accepts alongside the lease sources.
- Refs are <serverId>:<id>, since revert and reconcile get no params. The undo
  sends no idempotency key; a lost answer is reverted by key on every server.
  reconcile asks the plugin, and a server that cannot be asked keeps its rows.
- The refresh's bootId/wipeId watch calls ctx.events.reconcile() on a restart
  or a wipe, never on a first sighting or a reconnect (§11.1).
- The permission mirror keeps the plugin's new notLanded grants out of what it
  records as pushed, and the admin page says so (D85).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 01:26:57 -05:00

628 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 || []
const notLanded = report.notLanded || []
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 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>
)}
{notLanded.length > 0 && (
<Warn>
{notLanded.length} {notLanded.length === 1 ? 'grant was' : 'grants were'} sent and not found
in the game's permission store afterwards ({notLanded.slice(0, 5).join(', ')}
{notLanded.length > 5 ? ', …' : ''}). They are not counted as pushed, and the next sync
tries again.
</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 btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
Adopt
</button>
<button type="button" className="btn 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 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 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 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 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 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>
)
}