feat(rust): site-owned permissions — the site is the author, the game is the cache
R2, and the first phase where this module WRITES to a game. Groups and grants are authored on the website and pushed into each server's own permission store, so every plugin that already calls `UserHasPermission` honours them with no adapter, and a wipe stops being a data-loss event. **Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and resolved to every Steam id they have linked at push time (D28); every authored row carries a scope — a server or `*` (D29); groups are mirrored as real groups rather than flattened (D30); a holder the site did not author is REPORTED, never undone, with adopt and revoke offered (D31); one verb, with the plugin diffing locally (D32); a permission no server has registered is reported unresolved and never self-registered (D33); authoring is people and groups by hand, with rules deferred (D34). **Three sets, and every interesting question is a difference between two.** `desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because the site put it there and has since withdrawn it; `present − desired` is drift. The middle one is why `rust_perm_pushed` exists: a name in the store that is not in the desired set is either something the site retired or something a human granted, and those two have opposite correct answers. **What lands is not what was sent.** A grant naming a permission the server has not registered did not land — `GrantUserPermission` no-ops silently — and a member the store has never seen could not be placed. Neither is recorded as pushed, so the site never believes it gave a privilege it did not. The loop asks a cheap question every thirty seconds — does the digest of the desired set still equal what this server last confirmed — and syncs on a change, a restart, a wipe, a drift hook, a failed attempt past its backoff, or the fifteen-minute audit that finds drift on a server nobody has touched. **This module's first admin page**, because a permission model is the first thing here that has to be composed rather than configured. What is on it is decided by what an operator can get wrong: four states are invisible from the game and from a list of grants, and each is a sentence rather than a number. Walked end to end against a real core at the pinned ref, the real sidecar, and a stand-in speaking protocol 4 — including a restart that emptied the store and was fully re-pushed. Four defects the browser found that 133 green tests did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
617
client/src/routes/admin/Permissions.jsx
Normal file
617
client/src/routes/admin/Permissions.jsx
Normal file
@@ -0,0 +1,617 @@
|
||||
// ── Admin · Rust · Permissions ────────────────────────────────────────────
|
||||
//
|
||||
// R2's authoring surface, and this module's first admin page.
|
||||
//
|
||||
// **What is on it is decided by what an operator can get wrong**, rather than by
|
||||
// what the tables contain. Four states are invisible from the game and from a
|
||||
// list of grants, and every one of them looks exactly like success:
|
||||
//
|
||||
// • a grant against somebody who has linked no Steam account — authored,
|
||||
// stored, pushed nowhere;
|
||||
// • a permission no loaded plugin has registered — the grant lands silently
|
||||
// nowhere, because `GrantUserPermission` no-ops for an unregistered name;
|
||||
// • a group member who has never connected — the store has no user record to
|
||||
// put in a group yet, and the membership waits for their first connection;
|
||||
// • a server whose last sync failed — the site is authoritative and the game
|
||||
// has not heard it.
|
||||
//
|
||||
// So each of those is a sentence on this page rather than a number in a report.
|
||||
//
|
||||
// The screen never writes to a game. Every button here writes to the site and
|
||||
// the mirror's loop reconciles within seconds — except *Sync now*, which runs
|
||||
// that pass immediately because an operator who has just changed something
|
||||
// should not have to trust a timer to find out that a host is unreachable.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const FLEET = '*'
|
||||
|
||||
/** Shared furniture. The kit is nine exports and none of them is a table. */
|
||||
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 }}>
|
||||
<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, muted = false }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.86rem',
|
||||
color: muted ? 'var(--ink)' : 'var(--head)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Warn({ children }) {
|
||||
return (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function Scope({ value }) {
|
||||
return (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{value === FLEET ? 'every server' : value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One server's mirror state.
|
||||
*
|
||||
* `unresolved` and `pending` are rendered as sentences rather than counts
|
||||
* because each is a different problem with a different fix, and both are
|
||||
* invisible everywhere else on this page.
|
||||
*/
|
||||
function ServerState({ row, onSync, busy }) {
|
||||
const report = row.report || {}
|
||||
const unresolved = report.unresolved || []
|
||||
const pending = report.pending || []
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||
{row.serverId}
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ fontSize: '0.76rem', color: row.inSync ? 'var(--ink)' : '#d08a2a' }}
|
||||
>
|
||||
{row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" className="btn ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
|
||||
{busy ? 'Syncing…' : 'Sync now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{row.error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||||
{row.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{unresolved.length > 0 && (
|
||||
<Warn>
|
||||
{unresolved.join(', ')} — no plugin loaded on this server has registered{' '}
|
||||
{unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '}
|
||||
{unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when
|
||||
the plugin is back.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{pending.length > 0 && (
|
||||
<Warn>
|
||||
{pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a
|
||||
first connection — this server has never seen those players, so it has no account to put
|
||||
in a group yet.
|
||||
</Warn>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A hand edit, with the two answers to it. */
|
||||
function DriftRow({ row, onAdopt, onRevoke, busy }) {
|
||||
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||||
{row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
|
||||
{row.serverId} · seen {ago(row.firstSeen)}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="btn ghost" onClick={() => onAdopt(row)} disabled={busy}>
|
||||
Adopt
|
||||
</button>
|
||||
<button type="button" className="btn ghost" onClick={() => onRevoke(row)} disabled={busy}>
|
||||
Revoke
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The memberships the game could not place yet, as `steamId:group`.
|
||||
*
|
||||
* Read out of each server's own report, because it is the only thing that knows:
|
||||
* a member who has never connected to a server has no user record there to put
|
||||
* in a group (§12.2 rule 4), and from every other angle they look like a member.
|
||||
* The server strip says how many; this is what puts it next to the person.
|
||||
*/
|
||||
function pendingSet(servers) {
|
||||
const pending = new Map()
|
||||
|
||||
for (const server of servers) {
|
||||
for (const entry of (server.report && server.report.pending) || []) {
|
||||
if (!pending.has(entry)) pending.set(entry, [])
|
||||
pending.get(entry).push(server.serverId)
|
||||
}
|
||||
}
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [member, setMember] = useState('')
|
||||
const [permission, setPermission] = useState('')
|
||||
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = (permissions) =>
|
||||
act(() =>
|
||||
api.adminPermissions.saveGroup(group.name, {
|
||||
title: group.title,
|
||||
rank: group.rank,
|
||||
scope: group.scope,
|
||||
permissions,
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={group.title || group.name}
|
||||
subtitle={<>{group.name} · <Scope value={group.scope} /></>}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="field-label">Permissions</div>
|
||||
{group.permissions.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||
This group carries nothing, so being in it does nothing.
|
||||
</p>
|
||||
)}
|
||||
{group.permissions.map((perm) => (
|
||||
<Row key={perm}>
|
||||
<span style={{ flex: 1 }}>{perm}</span>
|
||||
{!catalogue.some((entry) => entry.permission === perm) && (
|
||||
<span className="sans" style={{ color: '#d08a2a', fontSize: '0.74rem' }}>
|
||||
no server has registered this
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
disabled={busy}
|
||||
onClick={() => save(group.permissions.filter((p) => p !== perm))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!permission.trim()) return
|
||||
save([...group.permissions, permission.trim().toLowerCase()])
|
||||
setPermission('')
|
||||
}}
|
||||
>
|
||||
<input
|
||||
list="rust-permission-names"
|
||||
className="input"
|
||||
placeholder="kits.vip"
|
||||
value={permission}
|
||||
onChange={(event) => setPermission(event.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Add permission
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="field-label" style={{ marginTop: 18 }}>
|
||||
Members
|
||||
</div>
|
||||
{group.members.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||
Nobody is in this group.
|
||||
</p>
|
||||
)}
|
||||
{group.members.map((m) => {
|
||||
const waiting = m.accounts
|
||||
.map((account) => pending.get(`${account.steamId}:${group.name}`))
|
||||
.filter(Boolean)
|
||||
.flat()
|
||||
|
||||
return (
|
||||
<Row key={m.userId}>
|
||||
<span style={{ flex: 1 }}>
|
||||
{m.username}
|
||||
{m.accounts.length > 0 ? (
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· {m.accounts.map((a) => a.name || a.steamId).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· has linked no Steam account, so this reaches nobody
|
||||
</span>
|
||||
)}
|
||||
{waiting.length > 0 && (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· waiting on their first connection to {[...new Set(waiting)].join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!member.trim()) return
|
||||
act(() => api.adminPermissions.addMember(group.name, member.trim()))
|
||||
setMember('')
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="website username"
|
||||
value={member}
|
||||
onChange={(event) => setMember(event.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Add member
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{servers.length > 1 && group.scope !== FLEET && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
This group exists on {group.scope} only. The other servers never receive it.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Permissions() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({ name: '', title: '', scope: FLEET })
|
||||
const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET })
|
||||
|
||||
const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads])
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
reload()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const servers = data.servers || []
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
{/* No heading of our own: core's admin chrome already draws the route's
|
||||
title above the page, and a second one is the same words twice. */}
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
This site is the author of record. Groups and grants written here are pushed into each
|
||||
server’s own permission store, so every plugin that checks a permission honours them — and a
|
||||
wipe does not lose them, because they are re-pushed when the server comes back.
|
||||
</p>
|
||||
|
||||
{/* The option source, shared by both forms. A datalist rather than a select:
|
||||
a name that no server has registered is still authorable — the plugin
|
||||
may simply not be loaded right now — and the warning beside it is the
|
||||
honest treatment, where a closed list would be a refusal. */}
|
||||
<datalist id="rust-permission-names">
|
||||
{(data.catalogue || []).map((entry) => (
|
||||
<option key={entry.permission} value={entry.permission} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title="Servers"
|
||||
subtitle={`${servers.length} configured`}
|
||||
actions={
|
||||
<button type="button" className="btn ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
|
||||
Sync all
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{servers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||
No servers are configured yet, so nothing written here reaches a game.
|
||||
</p>
|
||||
)}
|
||||
{servers.map((row) => (
|
||||
<ServerState
|
||||
key={row.serverId}
|
||||
row={row}
|
||||
busy={busy}
|
||||
onSync={(id) => act(() => api.adminPermissions.sync(id))}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{(data.drift || []).length > 0 && (
|
||||
<Card
|
||||
title="Changed in game"
|
||||
subtitle="granted at a console, not by this site"
|
||||
>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
|
||||
Nothing here is undone automatically. <strong>Adopt</strong> records it as the site’s
|
||||
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
|
||||
the next sync.
|
||||
</p>
|
||||
{data.drift.map((row) => (
|
||||
<DriftRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
busy={busy}
|
||||
onAdopt={(d) => act(() => api.adminPermissions.adoptDrift(d.id))}
|
||||
onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Direct grants" subtitle="one person, one permission">
|
||||
{(data.grants || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||
Nobody holds a permission of their own yet.
|
||||
</p>
|
||||
)}
|
||||
{(data.grants || []).map((row) => (
|
||||
<Row key={row.id}>
|
||||
<span style={{ flex: 1 }}>
|
||||
{row.username} · <strong style={{ fontWeight: 500 }}>{row.permission}</strong>{' '}
|
||||
<Scope value={row.scope} />
|
||||
{row.accounts.length === 0 && (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· has linked no Steam account, so this reaches nobody
|
||||
</span>
|
||||
)}
|
||||
{/* The same warning the group's permission list carries, and it
|
||||
matters more here: a grant naming a permission nothing has
|
||||
registered is the failure the plugin's pre-check exists for,
|
||||
and it is invisible on this row without it. */}
|
||||
{!(data.catalogue || []).some((entry) => entry.permission === row.permission) && (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· no server has registered this permission
|
||||
</span>
|
||||
)}
|
||||
{row.source !== 'admin' && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}> · {row.source}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!grant.username.trim() || !grant.permission.trim()) return
|
||||
act(() =>
|
||||
api.adminPermissions.grant({
|
||||
username: grant.username.trim(),
|
||||
permission: grant.permission.trim().toLowerCase(),
|
||||
scope: grant.scope,
|
||||
}),
|
||||
)
|
||||
setGrant({ username: '', permission: '', scope: FLEET })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="website username"
|
||||
value={grant.username}
|
||||
onChange={(event) => setGrant({ ...grant, username: event.target.value })}
|
||||
style={{ flex: '1 1 160px' }}
|
||||
/>
|
||||
<input
|
||||
list="rust-permission-names"
|
||||
className="input"
|
||||
placeholder="kits.vip"
|
||||
value={grant.permission}
|
||||
onChange={(event) => setGrant({ ...grant, permission: event.target.value })}
|
||||
style={{ flex: '1 1 160px' }}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={grant.scope}
|
||||
onChange={(event) => setGrant({ ...grant, scope: event.target.value })}
|
||||
>
|
||||
<option value={FLEET}>every server</option>
|
||||
{servers.map((row) => (
|
||||
<option key={row.serverId} value={row.serverId}>
|
||||
{row.serverId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Grant
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{(data.groups || []).map((group) => (
|
||||
<GroupCard
|
||||
key={group.name}
|
||||
group={group}
|
||||
catalogue={data.catalogue || []}
|
||||
servers={servers}
|
||||
pending={pendingSet(servers)}
|
||||
onChanged={reload}
|
||||
setError={setError}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Card title="New group">
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!form.name.trim()) return
|
||||
act(() =>
|
||||
api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), {
|
||||
title: form.title.trim() || form.name.trim(),
|
||||
scope: form.scope,
|
||||
permissions: [],
|
||||
}),
|
||||
)
|
||||
setForm({ name: '', title: '', scope: FLEET })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="vip"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
style={{ flex: '1 1 140px' }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="VIP"
|
||||
value={form.title}
|
||||
onChange={(event) => setForm({ ...form, title: event.target.value })}
|
||||
style={{ flex: '1 1 140px' }}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={form.scope}
|
||||
onChange={(event) => setForm({ ...form, scope: event.target.value })}
|
||||
>
|
||||
<option value={FLEET}>every server</option>
|
||||
{servers.map((row) => (
|
||||
<option key={row.serverId} value={row.serverId}>
|
||||
{row.serverId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
A group is created in each in-scope game as a real group, so plugins that read group
|
||||
membership see it. A member who has never connected to a server joins it there on their
|
||||
first connection — a direct grant reaches them straight away, which is the difference
|
||||
worth knowing when somebody is waiting.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user