// ── 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 (

{title}

{subtitle && ( {subtitle} )} {actions}
{children}
) } function Row({ children, muted = false }) { return (
{children}
) } function Warn({ children }) { return (

{children}

) } 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 (
{row.serverId} {row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'} {row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
{row.error && (

{row.error}

)} {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 )} ))}
{ event.preventDefault() if (!permission.trim()) return save([...group.permissions, permission.trim().toLowerCase()]) setPermission('') }} > setPermission(event.target.value)} style={{ flex: 1 }} />
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(', ')} )} ) })}
{ event.preventDefault() if (!member.trim()) return act(() => api.adminPermissions.addMember(group.name, member.trim())) setMember('') }} > setMember(event.target.value)} style={{ flex: 1 }} />
{servers.length > 1 && group.scope !== FLEET && (

This group exists on {group.scope} only. The other servers never receive it.

)}
) } 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 if (!data) return const servers = data.servers || [] return (
{/* 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. */} {(data.catalogue || []).map((entry) => ( {error && (

{error}

)} act(() => api.adminPermissions.sync())}> Sync all } > {servers.length === 0 && (

No servers are configured yet, so nothing written here reaches a game.

)} {servers.map((row) => ( act(() => api.adminPermissions.sync(id))} /> ))}
{(data.drift || []).length > 0 && (

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.drift.map((row) => ( act(() => api.adminPermissions.adoptDrift(d.id))} onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))} /> ))}
)} {(data.grants || []).length === 0 && (

Nobody holds a permission of their own yet.

)} {(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} )} ))}
{ 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 }) }} > setGrant({ ...grant, username: event.target.value })} style={{ flex: '1 1 160px' }} /> setGrant({ ...grant, permission: event.target.value })} style={{ flex: '1 1 160px' }} />
{(data.groups || []).map((group) => ( ))}
{ 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 }) }} > setForm({ ...form, name: event.target.value })} style={{ flex: '1 1 140px' }} /> setForm({ ...form, title: event.target.value })} style={{ flex: '1 1 140px' }} />

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.

) }