// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// The permission manager (PLAN_REDESIGNS §1). The site owns every permission and
// group on every server (D160), and this screen follows uMod PermissionsManager's
// flow (D162): a server, then players ⇄ groups, then a subject, then one plugin's
// permissions with Granted / Revoked, Grant all and Revoke all.
//
// Three rules shape it:
//
// • **Every toggle says what is true on THIS server** (U-1): granted,
// waiting for a sync, through a group, waiting for a first connection, not
// registered here, or did not land. A state in a server-level sentence is a
// state nobody reads.
// • **Plugins are who REGISTERED a permission, never its prefix.**
// `zonemanager.ignoreflag.nokits` is ZoneManager's.
// • **Anything that reaches further than this server says so and asks.** A
// fleet-wide grant is revoked everywhere or only here; a shared group is
// changed everywhere or split for this server (D190's two answers, offered
// to a person).
//
// Every subject is named by linked account and in-game name, or by Steam id
// when there is neither (D163). The screen never writes to a game: every button
// writes to the site, and the sync loop settles it within seconds — except
// *Sync now*.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
import Tabs from '../../components/Tabs.jsx'
import ChatStyleSection from './ChatStyle.jsx'
const POLICY_TEXT = {
'auto-adopt': 'A change made in the game becomes the site’s own, for that server.',
adopt: 'A change made in the game waits here for a person to adopt or undo it.',
revoke: 'A change made in the game is undone at the next sync.',
}
// ── Furniture ────────────────────────────────────────────────────────────
function Card({ title, subtitle, children, actions }) {
return (
{title}
{subtitle && {subtitle} }
{actions}
{children}
)
}
function Row({ children, onClick, selected = false }) {
return (
(e.key === 'Enter' ? onClick() : null) : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '7px 8px',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.85rem',
color: 'var(--head)',
cursor: onClick ? 'pointer' : undefined,
background: selected ? 'var(--blue)' : undefined,
borderRadius: selected ? 6 : undefined,
}}
>
{children}
)
}
const TONES = {
good: { color: '#5bb85b', border: '#3d7a3d' },
warn: { color: '#d08a2a', border: '#8a5c1c' },
bad: { color: '#e05a5a', border: '#8a3434' },
info: { color: 'var(--muted)', border: 'var(--line)' },
}
function Chip({ tone = 'info', children, title }) {
const t = TONES[tone]
return (
{children}
)
}
function Warn({ children }) {
return {children}
}
/** Granted / Revoked, as two buttons. */
function Toggle({ on, disabled, onChange }) {
const style = (active) => ({
padding: '2px 10px',
fontSize: '0.74rem',
cursor: disabled ? 'default' : 'pointer',
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
background: active ? 'var(--blue)' : 'transparent',
color: active ? 'var(--accent-bright)' : 'var(--muted)',
})
return (
!on && onChange(true)} style={{ ...style(on), borderRadius: '999px 0 0 999px' }}>Granted
on && onChange(false)} style={{ ...style(!on), borderRadius: '0 999px 999px 0' }}>Revoked
)
}
/** A subject's name, as D163 has it: account and in-game name, or the Steam id. */
function SubjectName({ player }) {
const primary = player.name || player.steamId
return (
{primary}
{player.account ? `site account ${player.account.username}` : 'not linked'}
{player.name ? ` · ${player.steamId}` : ''}
)
}
/** The plugin buttons: who registered each permission (§0.1). */
function PluginPicker({ plugins, active, onSelect, countFor }) {
return (
{plugins.map((p) => {
const n = countFor ? countFor(p) : 0
const selected = p.key === active
return (
onSelect(p.key)}
className="sans"
title={p.registered ? `Registered by ${p.label}` : `Not registered by a plugin — grouped by the prefix “${p.label}”`}
style={{
padding: '4px 10px',
fontSize: '0.78rem',
borderRadius: 999,
cursor: 'pointer',
border: `1px solid ${selected ? 'var(--accent)' : 'var(--line)'}`,
background: selected ? 'var(--blue)' : 'transparent',
color: selected ? 'var(--accent-bright)' : p.registered ? 'var(--head)' : 'var(--muted)',
}}
>
{p.label}{p.registered ? '' : ' *'}{n ? ` · ${n}` : ''}
)
})}
)
}
// ── The facts behind every toggle (U-1) ──────────────────────────────────
function useFacts(view) {
return useMemo(() => {
const landed = new Set(view.landed || [])
const report = view.report || { unresolved: [], pending: [], notLanded: [] }
const unresolved = new Set(report.unresolved.map((p) => String(p).toLowerCase()))
const pending = new Set(report.pending)
const notLanded = new Set(report.notLanded.map((p) => String(p).toLowerCase()))
const groupsByName = new Map(view.groups.map((g) => [g.name, g]))
const registered = new Set(view.plugins.flatMap((p) => p.permissions))
return { landed, unresolved, pending, notLanded, groupsByName, registered }
}, [view])
}
/** One player's state for one permission on this server. */
function playerState(facts, view, player, permission) {
const direct = player.grants.find((g) => g.permission === permission)
const viaGroups = player.groups.filter((name) => (facts.groupsByName.get(name) || { permissions: [] }).permissions.includes(permission))
const excepted = (view.excepted || []).find((e) => e.steamId === player.steamId && e.permission === permission)
if (direct) {
const wider = direct.sources.some((s) => (s.type === 'userGrant' || s.type === 'steamGrant') && s.scope !== view.server.id)
const fromEvent = direct.sources.every((s) => s.type === 'runGrant')
let chip
if (!facts.registered.has(permission)) chip = not registered here
else if (facts.notLanded.has(`${player.steamId}:${permission}`)) chip = did not land
else if (facts.landed.has(`grant ${player.steamId} ${permission}`)) chip = granted
else chip = waiting for a sync
return { on: true, chip, wider, fromEvent, excepted: null, viaGroups }
}
if (viaGroups.length) {
return { on: true, readOnly: true, chip: through {viaGroups.join(', ')} , viaGroups }
}
if (excepted) {
return { on: false, excepted, chip: revoked here only }
}
return { on: false, chip: null }
}
// ── A player (D162: a subject, its plugins, its groups) ──────────────────
function PlayerPanel({ view, player, act, busy }) {
const facts = useFacts(view)
const [tab, setTab] = useState('permissions')
const [plugin, setPlugin] = useState(view.plugins[0] ? view.plugins[0].key : null)
const [everywhere, setEverywhere] = useState(false)
const [asking, setAsking] = useState(null)
const [addGroup, setAddGroup] = useState('')
const serverId = view.server.id
const subject = { steamId: player.steamId }
const chosen = view.plugins.find((p) => p.key === plugin)
const held = (p) => p.permissions.filter((perm) => playerState(facts, view, player, perm).on).length
const toggle = (permission, on) => {
const state = playerState(facts, view, player, permission)
if (on) {
if (state.excepted) return act(() => api.adminPermissions.removeException(state.excepted.id))
return act(() => api.adminPermissions.grant(serverId, subject, [permission], everywhere))
}
// A grant that reaches further than this server: ask (D190's two answers).
if (state.wider) return setAsking({ permissions: [permission] })
return act(() => api.adminPermissions.revoke(serverId, subject, [permission]))
}
const bulk = (on) => {
if (!chosen) return
const perms = chosen.permissions.filter((perm) => {
const s = playerState(facts, view, player, perm)
return on ? !s.on : s.on && !s.readOnly
})
if (!perms.length) return
if (on) return act(() => api.adminPermissions.grant(serverId, subject, perms, everywhere))
if (perms.some((perm) => playerState(facts, view, player, perm).wider)) return setAsking({ permissions: perms })
return act(() => api.adminPermissions.revoke(serverId, subject, perms))
}
const groupIds = new Map(view.groups.map((g) => [g.name, g.id]))
return (
}
subtitle={`${player.grants.length} direct · ${player.groups.length} group${player.groups.length === 1 ? '' : 's'}`}
>
{tab === 'permissions' && (
<>
setEverywhere(e.target.checked)} />
Grant on every server, not just {view.server.name}
{asking && (
{asking.permissions.length === 1 ? {asking.permissions[0]} : `${asking.permissions.length} of these`} reach
{' '}further than {view.server.name}. Revoke:
{ const p = asking.permissions; setAsking(null); act(() => api.adminPermissions.revoke(serverId, subject, p, true)) }}>Everywhere
{ const p = asking.permissions; setAsking(null); act(() => api.adminPermissions.revoke(serverId, subject, p, false)) }}>Only on {view.server.name}
setAsking(null)}>Cancel
)}
{chosen && (
<>
bulk(true)}>Grant all
bulk(false)}>Revoke all
{chosen.permissions.map((perm) => {
const s = playerState(facts, view, player, perm)
return (
{perm}
{s.fromEvent && from an event }
{s.chip}
toggle(perm, on)} />
)
})}
>
)}
{!view.plugins.length && This server has not reported its plugins yet. Sync it once.
}
>
)}
{tab === 'groups' && (
<>
{player.groups.length === 0 && In no group on {view.server.name} (every player is in default).
}
{player.groups.map((name) => {
const waiting = facts.pending.has(`${player.steamId}:${name}`)
const landed = facts.landed.has(`member ${player.steamId} ${name}`)
return (
{name}
{waiting ? waiting for their first connection
: landed ? in the group : waiting for a sync }
act(() => api.adminPermissions.removeMember(groupIds.get(name), subject))}>Remove
)
})}
setAddGroup(e.target.value)}>
Add to a group…
{view.groups.filter((g) => g.name !== 'default' && !player.groups.includes(g.name)).map((g) => (
{g.name}{g.title && g.title !== g.name ? ` — ${g.title}` : ''}
))}
{ const id = addGroup; setAddGroup(''); act(() => api.adminPermissions.addMember(Number(id), subject)) }}>Add
{player.groups.length > 0 && (
act(async () => {
for (const name of player.groups) {
if (groupIds.has(name)) await api.adminPermissions.removeMember(groupIds.get(name), subject)
}
})}
>
Remove from all
)}
>
)}
)
}
// ── A group (D189, D190) ─────────────────────────────────────────────────
function GroupPanel({ view, group, act, busy, chatFields }) {
const facts = useFacts(view)
const [tab, setTab] = useState('permissions')
const [plugin, setPlugin] = useState(view.plugins[0] ? view.plugins[0].key : null)
const [scope, setScope] = useState('everywhere')
const [attrs, setAttrs] = useState({ title: group.title, rank: group.rank, parent: group.parent })
const [member, setMember] = useState('')
const [share, setShare] = useState({ allServers: group.allServers, servers: group.servers })
const [conflict, setConflict] = useState(null)
useEffect(() => {
setAttrs({ title: group.title, rank: group.rank, parent: group.parent })
setShare({ allServers: group.allServers, servers: group.servers })
setConflict(null)
}, [group])
const serverId = view.server.id
const here = group.shared && scope === 'here' ? { onlyHere: true, serverId } : null
const chosen = view.plugins.find((p) => p.key === plugin)
const carried = new Set(group.permissions)
const setPermissions = (next) => act(() => api.adminPermissions.setGroupPermissions(group.id, [...next].sort(), here))
const toggle = (perm, on) => {
const next = new Set(carried)
if (on) next.add(perm)
else next.delete(perm)
return setPermissions(next)
}
const bulk = (on) => {
if (!chosen) return
const next = new Set(carried)
for (const perm of chosen.permissions) on ? next.add(perm) : next.delete(perm)
return setPermissions(next)
}
const saveShare = async (replace = []) => {
const body = share.allServers ? { allServers: true, replace } : { allServers: false, servers: share.servers, replace }
try {
await act(() => api.adminPermissions.setGroupServers(group.id, body), { rethrow: true })
setConflict(null)
} catch (err) {
if (err && err.status === 409 && err.body && err.body.conflicts) setConflict(err.body)
}
}
const permState = (perm) => {
if (!facts.registered.has(perm)) return not registered here
if (!carried.has(perm)) return null
if (facts.landed.has(`group-permission ${group.name} ${perm}`)) return carried
return waiting for a sync
}
const members = [
...group.members.map((m) => ({ key: `u${m.userId}`, label: m.username, detail: m.steamIds.length ? m.steamIds.join(', ') : 'no Steam account linked — reaches nobody yet', subject: { userId: m.userId } })),
...group.steamMembers.map((steamId) => {
const p = view.players.find((x) => x.steamId === steamId)
return { key: `s${steamId}`, label: (p && p.name) || steamId, detail: p && p.account ? `site account ${p.account.username}` : steamId, subject: { steamId } }
}),
]
return (
{group.name}{group.builtin ? ' · built in' : ''} · {group.allServers ? 'every server' : group.servers.join(', ')}{group.source && group.source !== 'admin' ? ` · ${group.source}` : ''}>}
actions={!group.builtin && (
act(() => api.adminPermissions.deleteGroup(group.id))}>Delete
)}
>
{group.shared && (
shared
A change here applies
setScope('everywhere')} /> everywhere it is
setScope('here')} /> only on {view.server.name} (gives it its own copy)
)}
{tab === 'permissions' && (
<>
p.permissions.filter((x) => carried.has(x)).length} />
{chosen && (
<>
bulk(true)}>Grant all
bulk(false)}>Revoke all
{chosen.permissions.map((perm) => (
{perm}
{permState(perm)}
toggle(perm, on)} />
))}
>
)}
{group.permissions.filter((p) => !facts.registered.has(p)).length > 0 && (
It also carries {group.permissions.filter((p) => !facts.registered.has(p)).join(', ')}, which no plugin on this server registers now.
They land when that plugin loads.
)}
>
)}
{tab === 'members' && (
<>
{group.name === 'default' && Every player who connects is in default; it is not listed.
}
{members.map((m) => (
{m.label}
{m.detail}
act(() => api.adminPermissions.removeMember(group.id, m.subject, here))}>Remove
))}
setMember(e.target.value)} />
{
const v = member.trim()
setMember('')
act(() => api.adminPermissions.addMember(group.id, /^\d{5,20}$/.test(v) ? { steamId: v } : { username: v }, here))
}}
>
Add
{members.length > 0 && (
act(() => api.adminPermissions.clearMembers(group.id, here))}>Remove all
)}
>
)}
{tab === 'settings' && (
<>
{
try {
await act(() => api.adminPermissions.updateGroup(group.id, { chat }, here), { rethrow: true })
return true
} catch {
return false
}
}}
/>
>
)}
{tab === 'servers' && (
A group belongs to one server unless it is shared (D189). A shared group carries the same permissions and players on every server
it is on; a change made in one game gives that server its own copy.
setShare({ ...share, allServers: e.target.checked })} /> On every server, including servers added later
{!share.allServers && view.servers.map((s) => (
setShare({ ...share, servers: e.target.checked ? [...share.servers, s.id] : share.servers.filter((x) => x !== s.id) })}
/> {s.name}
))}
saveShare()}>Save
{conflict && (
{conflict.message}
This group carries: {(conflict.group.permissions || []).join(', ') || 'nothing'}
{conflict.conflicts.map((c) => (
The group on {c.servers.join(', ')} carries: {c.permissions.join(', ') || 'nothing'}
))}
saveShare(conflict.conflicts.map((c) => c.id))}>
Replace them with this group
{' '}
setConflict(null)}>Cancel
)}
)}
)
}
// ── What waits for a person (D161) ───────────────────────────────────────
const DIRECTION_TEXT = {
added: 'added in the game',
removed: 'removed in the game',
changed: 'changed in the game',
split: 'split off',
}
function DriftList({ rows, act, busy }) {
if (!rows.length) return null
return (
{rows.map((d) => {
const who = d.username ? `${d.playerName || d.subject} (${d.username})` : d.playerName || d.subject
const what = d.kind === 'grant' ? <>{who} holds {d.object}>
: d.kind === 'member' ? <>{who} is in {d.object}>
: d.kind === 'group-permission' ? <>group {d.subject} carries {d.object}>
: d.kind === 'chat-field' ? <>group {d.subject}’s {d.object} is {d.detail}>
: <>group {d.subject}>
return (
{what} — {DIRECTION_TEXT[d.direction] || d.direction}{d.detail === 'event' ? ' (an event gave it, so it was put back)' : ''}
{d.direction === 'split' && d.detail && {d.detail} }
{(d.direction === 'added' || d.direction === 'changed' || d.kind === 'chat-field') && d.detail !== 'event' && (
act(() => api.adminPermissions.adoptDrift(d.id))}>Adopt
)}
{(d.direction === 'added' || d.kind === 'chat-field') && (
act(() => api.adminPermissions.revokeDrift(d.id))}>Undo
)}
{d.direction === 'removed' && d.detail !== 'event' && (
act(() => api.adminPermissions.acceptDrift(d.id))}>Accept
)}
{(d.direction === 'removed' || d.direction === 'changed') && d.kind !== 'chat-field' && d.detail !== 'event' && (
act(() => api.adminPermissions.restoreDrift(d.id))}>Put back
)}
{(d.direction === 'split' || d.detail === 'event') && (
act(() => api.adminPermissions.dismissDrift(d.id))}>Dismiss
)}
)
})}
)
}
// ── One server ───────────────────────────────────────────────────────────
function ServerPanel({ serverId, chatFields, reloads, act, busy }) {
const { data: view, error } = useAsync(() => api.adminPermissions.server(serverId), [serverId, reloads])
const [tab, setTab] = useState('players')
const [selected, setSelected] = useState(null)
const [filter, setFilter] = useState('')
const [found, setFound] = useState([])
const [newGroup, setNewGroup] = useState('')
useEffect(() => { setSelected(null); setFilter(''); setFound([]) }, [serverId])
if (error) return
if (!view) return
const f = filter.trim().toLowerCase()
const players = view.players.filter((p) => !f || [p.name, p.steamId, p.account && p.account.username].some((v) => v && String(v).toLowerCase().includes(f)))
// A player found by search who holds nothing yet: shown with empty holdings.
const player = selected && selected.type === 'player'
? view.players.find((p) => p.steamId === selected.steamId) || { ...selected.found, grants: [], groups: [] }
: null
const group = selected && selected.type === 'group' ? view.groups.find((g) => g.id === selected.id) : null
return (
<>
{ setTab(t); setSelected(null) }} label="Permissions" />
{tab === 'players' && (
<>
setFilter(e.target.value)}
onKeyDown={async (e) => {
if (e.key !== 'Enter' || !filter.trim()) return
const res = await api.adminPermissions.players(serverId, filter.trim())
setFound(res.players || [])
}}
style={{ width: '100%', marginBottom: 6 }}
/>
Enter searches every player this server has seen.
{players.map((p) => (
setSelected({ type: 'player', steamId: p.steamId })}>
))}
{found.filter((p) => !view.players.some((x) => x.steamId === p.steamId)).map((p) => (
setSelected({ type: 'player', steamId: p.steamId, found: p })}>
holds nothing
))}
{!players.length && !found.length &&
Nobody holds anything here yet.
}
>
)}
{tab === 'groups' && (
<>
{view.groups.map((g) => (
setSelected({ type: 'group', id: g.id })}>
{g.name}
{g.shared && shared }
{g.permissions.length}
))}
>
)}
{player &&
}
{group &&
}
{!player && !group && (
Choose a {tab === 'players' ? 'player' : 'group'} on the left.
)}
>
)
}
// ── The page ─────────────────────────────────────────────────────────────
export default function Permissions() {
const [reloads, setReloads] = useState(0)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [serverId, setServerId] = useState(null)
const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads])
const reload = useCallback(() => setReloads((n) => n + 1), [])
const act = async (fn, { rethrow = false } = {}) => {
setBusy(true)
setError('')
try {
await fn()
reload()
} catch (err) {
if (!(rethrow && err && err.status === 409)) setError(err.message || 'That did not work.')
if (rethrow) throw err
} finally {
setBusy(false)
}
}
useEffect(() => {
if (!serverId && data && data.servers.length) setServerId(data.servers[0].id)
}, [data, serverId])
if (loadError) return
if (!data) return
const server = data.servers.find((s) => s.id === serverId)
const sync = server && server.sync
return (
This site holds every permission and group on each server — what was there before it, what is made here, and
what is changed in the game. It reads each server’s store and pushes the site’s set back, so a wipe loses nothing.
{error &&
{error}
}
Server{' '}
setServerId(e.target.value)}>
{data.servers.map((s) => {s.name} )}
{server && (
<>
In-game changes{' '}
act(() => api.adminPermissions.setPolicy(server.id, e.target.value))}>
{data.policies.map((p) => {p} )}
{sync && (
sync.inSync ? in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}
: sync.state === 'failed' ? last sync failed
: waiting for a sync
)}
{sync && !sync.importedAt && not imported yet }
act(() => api.adminPermissions.sync(server.id))}>Sync now
>
)}
{server &&
{POLICY_TEXT[server.policy]}
}
{sync && sync.state === 'failed' && sync.error &&
{sync.error} }
{serverId &&
}
{!data.servers.length &&
No Rust servers are configured yet.
}
)
}