// ── 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 ( ) } /** 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 ( ) })}
) } // ── 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' && ( <> {asking && (
{asking.permissions.length === 1 ? {asking.permissions[0]} : `${asking.permissions.length} of these`} reach {' '}further than {view.server.name}. Revoke:
)} {chosen && ( <>
{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} ) })}
{player.groups.length > 0 && ( )}
)}
) } // ── 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 && ( )} > {group.shared && (
shared A change here applies
)} {tab === 'permissions' && ( <> p.permissions.filter((x) => carried.has(x)).length} /> {chosen && ( <>
{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} ))}
setMember(e.target.value)} /> {members.length > 0 && ( )}
)} {tab === 'settings' && ( <>
{ e.preventDefault(); act(() => api.adminPermissions.updateGroup(group.id, attrs, here)) }} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 8, fontSize: '0.82rem' }} >
{ 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.

{!share.allServers && view.servers.map((s) => ( ))} {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'}

))} {' '}
)}
)}
) } // ── 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' && ( )} {(d.direction === 'added' || d.kind === 'chat-field') && ( )} {d.direction === 'removed' && d.detail !== 'event' && ( )} {(d.direction === 'removed' || d.direction === 'changed') && d.kind !== 'chat-field' && d.detail !== 'event' && ( )} {(d.direction === 'split' || d.detail === 'event') && ( )} ) })} ) } // ── 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} ))}
{ e.preventDefault(); const name = newGroup.trim(); if (!name) return; setNewGroup(''); act(() => api.adminPermissions.createGroup(serverId, { name, title: name })) }} style={{ display: 'flex', gap: 6, marginTop: 10 }} > setNewGroup(e.target.value)} style={{ flex: 1 }} />
)}
{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 && ( <> {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} )}
{server &&

{POLICY_TEXT[server.policy]}

} {sync && sync.state === 'failed' && sync.error && {sync.error}} {serverId && } {!data.servers.length &&

No Rust servers are configured yet.

}
) }