Files
Module-Rust/client/src/routes/admin/Permissions.jsx
wtclaude e0d13e73db
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 7m58s
feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
PLAN_REDESIGNS section 1.

- Every sync reads the store (perm.inventory), reconciles it against the
  site's record and its ledger, and pushes. A change made in the game is
  settled by the server's policy (D161): auto-adopt (default), adopt, or
  revoke. The first read of a server imports everything (D198).
- Groups belong to one server unless an admin shares them (D189), in new
  id-keyed tables; the old ones are copied once at boot and left unread.
  Holders may be a Steam account nobody linked (D188).
- An in-game change affects that server only (D190): a grant that reaches
  further gains an exception, a shared group is split.
- Never judged: a permission the server does not register right now (an
  unloaded plugin is not a revocation), and a pair an event lease holds.
- A new admin API (server view, grant/revoke with everywhere-or-here,
  groups by id, share/split, members, drift answers) and a screen on
  PermissionsManager's flow with a state on every toggle (D162, D163, U-1).
- The announcement voice names a group by id; old name settings still read.

Walked on both rigs against the walk core: import on an existing install,
auto-adopt of a grant and a revoke, a fleet grant's exception, Kits
unloaded without loss, a shared group split, adopt and revoke policies.
Server 420/420, client 58/58, swagger, imports and route manifest current.

Refs #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-28 06:58:59 -05:00

797 lines
38 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 ────────────────────────────────────────────
//
// 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 (
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<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, onClick, selected = false }) {
return (
<div
className="sans"
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={onClick ? (e) => (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}
</div>
)
}
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 (
<span
title={title}
className="sans"
style={{ fontSize: '0.7rem', padding: '1px 8px', borderRadius: 999, border: `1px solid ${t.border}`, color: t.color, whiteSpace: 'nowrap' }}
>
{children}
</span>
)
}
function Warn({ children }) {
return <p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>{children}</p>
}
/** 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 (
<span style={{ display: 'inline-flex' }}>
<button type="button" disabled={disabled} onClick={() => !on && onChange(true)} style={{ ...style(on), borderRadius: '999px 0 0 999px' }}>Granted</button>
<button type="button" disabled={disabled} onClick={() => on && onChange(false)} style={{ ...style(!on), borderRadius: '0 999px 999px 0' }}>Revoked</button>
</span>
)
}
/** 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 (
<span style={{ display: 'inline-flex', flexDirection: 'column' }}>
<span>{primary}</span>
<span className="dim" style={{ fontSize: '0.72rem' }}>
{player.account ? `site account ${player.account.username}` : 'not linked'}
{player.name ? ` · ${player.steamId}` : ''}
</span>
</span>
)
}
/** The plugin buttons: who registered each permission (§0.1). */
function PluginPicker({ plugins, active, onSelect, countFor }) {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, margin: '6px 0 12px' }}>
{plugins.map((p) => {
const n = countFor ? countFor(p) : 0
const selected = p.key === active
return (
<button
key={p.key}
type="button"
onClick={() => 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}` : ''}
</button>
)
})}
</div>
)
}
// ── 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 = <Chip tone="bad" title="No loaded plugin registers it here">not registered here</Chip>
else if (facts.notLanded.has(`${player.steamId}:${permission}`)) chip = <Chip tone="bad">did not land</Chip>
else if (facts.landed.has(`grant ${player.steamId} ${permission}`)) chip = <Chip tone="good">granted</Chip>
else chip = <Chip tone="warn">waiting for a sync</Chip>
return { on: true, chip, wider, fromEvent, excepted: null, viaGroups }
}
if (viaGroups.length) {
return { on: true, readOnly: true, chip: <Chip tone="info">through {viaGroups.join(', ')}</Chip>, viaGroups }
}
if (excepted) {
return { on: false, excepted, chip: <Chip tone="warn" title="Granted on every other server">revoked here only</Chip> }
}
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 (
<Card
title={<SubjectName player={player} />}
subtitle={`${player.grants.length} direct · ${player.groups.length} group${player.groups.length === 1 ? '' : 's'}`}
>
<Tabs
tabs={[{ id: 'permissions', label: 'Permissions' }, { id: 'groups', label: 'Groups' }]}
active={tab}
onSelect={setTab}
label="Player"
/>
{tab === 'permissions' && (
<>
<label className="sans dim" style={{ fontSize: '0.78rem', display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={everywhere} onChange={(e) => setEverywhere(e.target.checked)} />
Grant on every server, not just {view.server.name}
</label>
<PluginPicker plugins={view.plugins} active={plugin} onSelect={setPlugin} countFor={held} />
{asking && (
<div className="panel sans" style={{ padding: '10px 12px', marginBottom: 10, fontSize: '0.82rem' }}>
{asking.permissions.length === 1 ? <code>{asking.permissions[0]}</code> : `${asking.permissions.length} of these`} reach
{' '}further than {view.server.name}. Revoke:
<span style={{ display: 'inline-flex', gap: 6, marginLeft: 8 }}>
<button type="button" className="btn" disabled={busy} onClick={() => { const p = asking.permissions; setAsking(null); act(() => api.adminPermissions.revoke(serverId, subject, p, true)) }}>Everywhere</button>
<button type="button" className="btn" disabled={busy} onClick={() => { const p = asking.permissions; setAsking(null); act(() => api.adminPermissions.revoke(serverId, subject, p, false)) }}>Only on {view.server.name}</button>
<button type="button" className="btn btn-ghost" onClick={() => setAsking(null)}>Cancel</button>
</span>
</div>
)}
{chosen && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk(true)}>Grant all</button>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk(false)}>Revoke all</button>
</div>
{chosen.permissions.map((perm) => {
const s = playerState(facts, view, player, perm)
return (
<Row key={perm}>
<code style={{ flex: 1, fontSize: '0.8rem' }}>{perm}</code>
{s.fromEvent && <Chip tone="info" title="An event gave it; the event’s revert takes it back">from an event</Chip>}
{s.chip}
<Toggle on={s.on} disabled={busy || s.readOnly || s.fromEvent} onChange={(on) => toggle(perm, on)} />
</Row>
)
})}
</>
)}
{!view.plugins.length && <p className="sans dim">This server has not reported its plugins yet. Sync it once.</p>}
</>
)}
{tab === 'groups' && (
<>
{player.groups.length === 0 && <p className="sans dim" style={{ fontSize: '0.82rem' }}>In no group on {view.server.name} (every player is in <code>default</code>).</p>}
{player.groups.map((name) => {
const waiting = facts.pending.has(`${player.steamId}:${name}`)
const landed = facts.landed.has(`member ${player.steamId} ${name}`)
return (
<Row key={name}>
<span style={{ flex: 1 }}>{name}</span>
{waiting ? <Chip tone="warn" title="The server has never seen this player">waiting for their first connection</Chip>
: landed ? <Chip tone="good">in the group</Chip> : <Chip tone="warn">waiting for a sync</Chip>}
<button type="button" className="btn btn-ghost" disabled={busy || !groupIds.has(name)} onClick={() => act(() => api.adminPermissions.removeMember(groupIds.get(name), subject))}>Remove</button>
</Row>
)
})}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<select className="input" value={addGroup} onChange={(e) => setAddGroup(e.target.value)}>
<option value="">Add to a group…</option>
{view.groups.filter((g) => g.name !== 'default' && !player.groups.includes(g.name)).map((g) => (
<option key={g.id} value={g.id}>{g.name}{g.title && g.title !== g.name ? ` — ${g.title}` : ''}</option>
))}
</select>
<button type="button" className="btn" disabled={busy || !addGroup} onClick={() => { const id = addGroup; setAddGroup(''); act(() => api.adminPermissions.addMember(Number(id), subject)) }}>Add</button>
{player.groups.length > 0 && (
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(async () => {
for (const name of player.groups) {
if (groupIds.has(name)) await api.adminPermissions.removeMember(groupIds.get(name), subject)
}
})}
>
Remove from all
</button>
)}
</div>
</>
)}
</Card>
)
}
// ── 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 <Chip tone="bad">not registered here</Chip>
if (!carried.has(perm)) return null
if (facts.landed.has(`group-permission ${group.name} ${perm}`)) return <Chip tone="good">carried</Chip>
return <Chip tone="warn">waiting for a sync</Chip>
}
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 (
<Card
title={group.title || group.name}
subtitle={<>{group.name}{group.builtin ? ' · built in' : ''} · {group.allServers ? 'every server' : group.servers.join(', ')}{group.source && group.source !== 'admin' ? ` · ${group.source}` : ''}</>}
actions={!group.builtin && (
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.deleteGroup(group.id))}>Delete</button>
)}
>
{group.shared && (
<div className="sans" style={{ fontSize: '0.8rem', marginBottom: 10, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<Chip tone="warn">shared</Chip>
<span className="dim">A change here applies</span>
<label><input type="radio" checked={scope === 'everywhere'} onChange={() => setScope('everywhere')} /> everywhere it is</label>
<label><input type="radio" checked={scope === 'here'} onChange={() => setScope('here')} /> only on {view.server.name} (gives it its own copy)</label>
</div>
)}
<Tabs
tabs={[
{ id: 'permissions', label: 'Permissions' },
{ id: 'members', label: `Players (${members.length})` },
{ id: 'settings', label: 'Settings' },
{ id: 'servers', label: 'Servers' },
]}
active={tab}
onSelect={setTab}
label="Group"
/>
{tab === 'permissions' && (
<>
<PluginPicker plugins={view.plugins} active={plugin} onSelect={setPlugin} countFor={(p) => p.permissions.filter((x) => carried.has(x)).length} />
{chosen && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk(true)}>Grant all</button>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk(false)}>Revoke all</button>
</div>
{chosen.permissions.map((perm) => (
<Row key={perm}>
<code style={{ flex: 1, fontSize: '0.8rem' }}>{perm}</code>
{permState(perm)}
<Toggle on={carried.has(perm)} disabled={busy} onChange={(on) => toggle(perm, on)} />
</Row>
))}
</>
)}
{group.permissions.filter((p) => !facts.registered.has(p)).length > 0 && (
<Warn>
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.
</Warn>
)}
</>
)}
{tab === 'members' && (
<>
{group.name === 'default' && <p className="sans dim" style={{ fontSize: '0.82rem' }}>Every player who connects is in <code>default</code>; it is not listed.</p>}
{members.map((m) => (
<Row key={m.key}>
<span style={{ flex: 1, display: 'inline-flex', flexDirection: 'column' }}>
<span>{m.label}</span>
<span className="dim" style={{ fontSize: '0.72rem' }}>{m.detail}</span>
</span>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.removeMember(group.id, m.subject, here))}>Remove</button>
</Row>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
<input className="input" placeholder="Steam id or site account name" value={member} onChange={(e) => setMember(e.target.value)} />
<button
type="button"
className="btn"
disabled={busy || !member.trim()}
onClick={() => {
const v = member.trim()
setMember('')
act(() => api.adminPermissions.addMember(group.id, /^\d{5,20}$/.test(v) ? { steamId: v } : { username: v }, here))
}}
>
Add
</button>
{members.length > 0 && (
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.clearMembers(group.id, here))}>Remove all</button>
)}
</div>
</>
)}
{tab === 'settings' && (
<>
<form
className="sans"
onSubmit={(e) => { 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' }}
>
<label>Title<input className="input" value={attrs.title} onChange={(e) => setAttrs({ ...attrs, title: e.target.value })} /></label>
<label>Rank<input className="input" type="number" value={attrs.rank} onChange={(e) => setAttrs({ ...attrs, rank: Number(e.target.value) })} /></label>
<label>
Parent
<select className="input" value={attrs.parent} onChange={(e) => setAttrs({ ...attrs, parent: e.target.value })}>
<option value="">None</option>
{view.groups.filter((g) => g.name !== group.name).map((g) => <option key={g.id} value={g.name}>{g.name}</option>)}
</select>
</label>
<span style={{ alignSelf: 'end' }}><button type="submit" className="btn" disabled={busy}>Save</button></span>
</form>
<ChatStyleSection
group={group}
fields={chatFields}
servers={view.sync ? [{ serverId: view.server.id, report: view.sync.report }] : []}
busy={busy}
onSave={async (chat) => {
try {
await act(() => api.adminPermissions.updateGroup(group.id, { chat }, here), { rethrow: true })
return true
} catch {
return false
}
}}
/>
</>
)}
{tab === 'servers' && (
<div className="sans" style={{ fontSize: '0.82rem' }}>
<p className="dim" style={{ margin: '0 0 8px' }}>
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.
</p>
<label style={{ display: 'block', marginBottom: 6 }}>
<input type="checkbox" checked={share.allServers} onChange={(e) => setShare({ ...share, allServers: e.target.checked })} /> On every server, including servers added later
</label>
{!share.allServers && view.servers.map((s) => (
<label key={s.id} style={{ display: 'block' }}>
<input
type="checkbox"
checked={share.servers.includes(s.id)}
onChange={(e) => setShare({ ...share, servers: e.target.checked ? [...share.servers, s.id] : share.servers.filter((x) => x !== s.id) })}
/> {s.name}
</label>
))}
<button type="button" className="btn" style={{ marginTop: 8 }} disabled={busy} onClick={() => saveShare()}>Save</button>
{conflict && (
<div className="panel" style={{ padding: '10px 12px', marginTop: 10 }}>
<p style={{ margin: '0 0 6px' }}>{conflict.message}</p>
<p className="dim" style={{ margin: '0 0 6px' }}>This group carries: {(conflict.group.permissions || []).join(', ') || 'nothing'}</p>
{conflict.conflicts.map((c) => (
<p key={c.id} style={{ margin: '0 0 4px' }}>
The group on {c.servers.join(', ')} carries: {c.permissions.join(', ') || 'nothing'}
</p>
))}
<button type="button" className="btn" disabled={busy} onClick={() => saveShare(conflict.conflicts.map((c) => c.id))}>
Replace them with this group
</button>{' '}
<button type="button" className="btn btn-ghost" onClick={() => setConflict(null)}>Cancel</button>
</div>
)}
</div>
)}
</Card>
)
}
// ── 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 (
<Card title="Waiting for a person" subtitle={`${rows.length}`}>
{rows.map((d) => {
const who = d.username ? `${d.playerName || d.subject} (${d.username})` : d.playerName || d.subject
const what = d.kind === 'grant' ? <>{who} holds <code>{d.object}</code></>
: d.kind === 'member' ? <>{who} is in <code>{d.object}</code></>
: d.kind === 'group-permission' ? <>group <code>{d.subject}</code> carries <code>{d.object}</code></>
: d.kind === 'chat-field' ? <>group <code>{d.subject}</code>’s {d.object} is <code>{d.detail}</code></>
: <>group <code>{d.subject}</code></>
return (
<Row key={d.id}>
<span style={{ flex: 1 }}>
{what} — <span className="dim">{DIRECTION_TEXT[d.direction] || d.direction}{d.detail === 'event' ? ' (an event gave it, so it was put back)' : ''}</span>
{d.direction === 'split' && d.detail && <span className="dim" style={{ display: 'block', fontSize: '0.74rem' }}>{d.detail}</span>}
</span>
{(d.direction === 'added' || d.direction === 'changed' || d.kind === 'chat-field') && d.detail !== 'event' && (
<button type="button" className="btn" disabled={busy} onClick={() => act(() => api.adminPermissions.adoptDrift(d.id))}>Adopt</button>
)}
{(d.direction === 'added' || d.kind === 'chat-field') && (
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.revokeDrift(d.id))}>Undo</button>
)}
{d.direction === 'removed' && d.detail !== 'event' && (
<button type="button" className="btn" disabled={busy} onClick={() => act(() => api.adminPermissions.acceptDrift(d.id))}>Accept</button>
)}
{(d.direction === 'removed' || d.direction === 'changed') && d.kind !== 'chat-field' && d.detail !== 'event' && (
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.restoreDrift(d.id))}>Put back</button>
)}
{(d.direction === 'split' || d.detail === 'event') && (
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.dismissDrift(d.id))}>Dismiss</button>
)}
</Row>
)
})}
</Card>
)
}
// ── 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 <ErrorState error={error} />
if (!view) return <Loading />
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 (
<>
<Tabs tabs={[{ id: 'players', label: `Players (${view.players.length})` }, { id: 'groups', label: `Groups (${view.groups.length})` }]} active={tab} onSelect={(t) => { setTab(t); setSelected(null) }} label="Permissions" />
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', gap: 18, alignItems: 'start' }}>
<div className="panel" style={{ padding: '10px 10px' }}>
{tab === 'players' && (
<>
<input
className="input"
placeholder="Find a player…"
value={filter}
onChange={(e) => 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 }}
/>
<p className="sans dim" style={{ fontSize: '0.7rem', margin: '0 0 6px' }}>Enter searches every player this server has seen.</p>
{players.map((p) => (
<Row key={p.steamId} selected={player && player.steamId === p.steamId} onClick={() => setSelected({ type: 'player', steamId: p.steamId })}>
<SubjectName player={p} />
</Row>
))}
{found.filter((p) => !view.players.some((x) => x.steamId === p.steamId)).map((p) => (
<Row key={`f${p.steamId}`} onClick={() => setSelected({ type: 'player', steamId: p.steamId, found: p })}>
<SubjectName player={p} /> <Chip>holds nothing</Chip>
</Row>
))}
{!players.length && !found.length && <p className="sans dim" style={{ fontSize: '0.8rem' }}>Nobody holds anything here yet.</p>}
</>
)}
{tab === 'groups' && (
<>
{view.groups.map((g) => (
<Row key={g.id} selected={group && group.id === g.id} onClick={() => setSelected({ type: 'group', id: g.id })}>
<span style={{ flex: 1 }}>{g.name}</span>
{g.shared && <Chip tone="warn">shared</Chip>}
<span className="dim" style={{ fontSize: '0.72rem' }}>{g.permissions.length}</span>
</Row>
))}
<form
onSubmit={(e) => { 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 }}
>
<input className="input" placeholder="New group name" value={newGroup} onChange={(e) => setNewGroup(e.target.value)} style={{ flex: 1 }} />
<button type="submit" className="btn" disabled={busy || !newGroup.trim()}>Create</button>
</form>
</>
)}
</div>
<div>
{player && <PlayerPanel view={view} player={player} act={act} busy={busy} />}
{group && <GroupPanel view={view} group={group} act={act} busy={busy} chatFields={chatFields} />}
{!player && !group && (
<p className="sans dim" style={{ fontSize: '0.84rem' }}>
Choose a {tab === 'players' ? 'player' : 'group'} on the left.
</p>
)}
<DriftList rows={view.drift} act={act} busy={busy} />
</div>
</div>
</>
)
}
// ── 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 <ErrorState error={loadError} />
if (!data) return <Loading />
const server = data.servers.find((s) => s.id === serverId)
const sync = server && server.sync
return (
<div style={{ maxWidth: 1100 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
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.
</p>
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>{error}</p>}
<div className="sans" style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 14 }}>
<label style={{ fontSize: '0.84rem' }}>
Server{' '}
<select className="input" value={serverId || ''} onChange={(e) => setServerId(e.target.value)}>
{data.servers.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
{server && (
<>
<label style={{ fontSize: '0.84rem' }}>
In-game changes{' '}
<select className="input" value={server.policy} disabled={busy} onChange={(e) => act(() => api.adminPermissions.setPolicy(server.id, e.target.value))}>
{data.policies.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
{sync && (
sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
: <Chip tone="warn">waiting for a sync</Chip>
)}
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server (D198)">not imported yet</Chip>}
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync(server.id))}>Sync now</button>
</>
)}
</div>
{server && <p className="sans dim" style={{ fontSize: '0.78rem', margin: '-6px 0 14px' }}>{POLICY_TEXT[server.policy]}</p>}
{sync && sync.state === 'failed' && sync.error && <Warn>{sync.error}</Warn>}
{serverId && <ServerPanel serverId={serverId} chatFields={data.chatFields} reloads={reloads} act={act} busy={busy} />}
{!data.servers.length && <p className="sans dim">No Rust servers are configured yet.</p>}
</div>
)
}