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
797 lines
38 KiB
JavaScript
797 lines
38 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')
|
||
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>
|
||
)
|
||
}
|