// ── 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 (
)
}
function Scope({ value }) {
return (
{value === FLEET ? 'every server' : value}
)
}
/**
* 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 (
)}
{unresolved.length > 0 && (
{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.
)}
{pending.length > 0 && (
{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.
)}
)
}
/** 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.object}{' '}
{row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
{row.serverId} · seen {ago(row.firstSeen)}
)
}
/**
* 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 (
{group.name} · >}
actions={
}
>
Permissions
{group.permissions.length === 0 && (
This group carries nothing, so being in it does nothing.
)}
{group.permissions.map((perm) => (
{perm}
{!catalogue.some((entry) => entry.permission === perm) && (
no server has registered this
)}
))}
Members
{group.members.length === 0 && (
Nobody is in this group.
)}
{group.members.map((m) => {
const waiting = m.accounts
.map((account) => pending.get(`${account.steamId}:${group.name}`))
.filter(Boolean)
.flat()
return (
{m.username}
{m.accounts.length > 0 ? (
{' '}
· {m.accounts.map((a) => a.name || a.steamId).join(', ')}
) : (
{' '}
· has linked no Steam account, so this reaches nobody
)}
{waiting.length > 0 && (
{' '}
· waiting on their first connection to {[...new Set(waiting)].join(', ')}
)}
)
})}
{servers.length > 1 && group.scope !== FLEET && (
This group exists on {group.scope} only. The other servers never receive it.
{/* 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. */}
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.
{/* 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. */}
{error && (
Nothing here is undone automatically. Adopt records it as the site’s
own, so it survives the next wipe; Revoke removes it from the game on
the next sync.
)}
{(data.grants || []).map((row) => (
{row.username} · {row.permission}{' '}
{row.accounts.length === 0 && (
{' '}
· has linked no Steam account, so this reaches nobody
)}
{/* 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) && (
{' '}
· no server has registered this permission
)}
{row.source !== 'admin' && (
· {row.source}
)}
))}
{(data.groups || []).map((group) => (
))}
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.