Twenty-four strings an administrator reads carried an internal decision number - "(D189)", "(D198)", "(D257, D270)" and the like: the permission screen's group Servers tab and a chip tooltip, the event editor's step field and budget descriptions, two option sources, a command reward's skipped note, and five NPC profile validation errors. A D-number means something to the plan, never to an operator. Each is removed, and the sentence around it left as it was. The developer-facing swagger descriptions and code comments keep theirs. The servers page's chat-title line read "Last pushed: 1 player hold one." It now says "holds" for one. Two tests pinned the old wording and now assert the text instead. Found while writing the runicgateway.com Rust docs (its PLAN.md §16, phase 15). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
807 lines
39 KiB
JavaScript
807 lines
39 KiB
JavaScript
// ── 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')
|
||
// Given for good by an event (PLAN_REDESIGNS.md §12.5): a standing grant like
|
||
// a staff member's, toggled off like one, and marked with its note.
|
||
const given = direct.sources.find((s) => s.type === 'userGrant' && s.origin === 'event')
|
||
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, given: given ? given.note || 'an event' : null, 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.given && <Chip tone="info" title={`Given for good by ${s.given}; turn it off to take it back`}>given by {s.given}</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. 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 && sync.waitingToLand > 0
|
||
? (
|
||
<Chip tone="warn" title="A grant to a Steam id this server has never seen lands when that player first joins: every sync pushes it again">
|
||
in sync · {sync.waitingToLand} {sync.waitingToLand === 1 ? 'waits' : 'wait'} for their player to join
|
||
</Chip>
|
||
)
|
||
: 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">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>
|
||
)
|
||
}
|