feat(rust): mod configuration from the site, and an editor that will not rewrite a float
All checks were successful
PR Checks / server-tests (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / client-build (pull_request) Successful in 7m56s

R18's two tiers: a form generated from a config file's own values, and raw JSON
for what a form cannot express. Admin → Rust mod config, one live round trip per
action, nothing cached between a browser and a game host's disk.

`configEdit.js` is the part that could not be done naively. JavaScript cannot
tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C#
classes — so a read-modify-write silently rewrites every whole-numbered float as
an integer on fields nobody touched, and a plugin that then throws at load does
not come back. It never parses, mutates and re-serialises: it records the SOURCE
SPAN of every value and splices literals into them, so an untouched `1.0` is
still `1.0` and a number an admin types travels as text the whole way (D35/D36).

The bridge's own config is editable with `Host`, `Port` and `ServerId` locked,
in the form and in the raw tier, because either would cut the link carrying the
edit or strand every row this site holds (D38). Credentials render masked with a
reveal; the raw tier shows them (D37) and the audit trail never does.

`rust_config_writes` records every save including the refused and the rolled
back — an operator asking why a setting is not what they set needs to see that
somebody tried.

Three defects a browser walk found that 179 green tests did not:

* every save of the bridge's own config was refused while the page said the
  opposite — a `<select>` whose value matches no `<option>` shows the first one,
  so the reload guess `RunicGateway` was on the wire and "nothing" was on the
  screen;
* `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every
  secondary button in this module has rendered as a primary one since phase 7 —
  here it made the open file and the active tier indistinguishable;
* a save's refusal rendered at the top of a long form, far from the button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-22 08:55:28 -05:00
parent b1abd87c3d
commit e54ae3afb9
21 changed files with 2862 additions and 21 deletions

View File

@@ -147,6 +147,34 @@ export const adminPermissions = {
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
}
// ── admin · mod configuration (R18) ───────────────────────────────────────
//
// Every call here is a LIVE round trip to a game host, which makes this the only
// section of this file where a call can be slow, or fail because a server is
// off. Nothing is cached anywhere between the browser and the host's disk: a
// cached config is an edit an operator made over SSH that this website then
// silently overwrote.
//
// `save` carries a `version` the host issued with the file. Send a stale one and
// the answer is a 409 with the current file attached, rather than an overwrite
// of whatever somebody else changed in the meantime.
export const adminConfig = {
files: (serverId) => req(`/admin/rust/config/${encodeURIComponent(serverId)}/files`),
file: (serverId, path) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file${query({ path })}`),
// Two tiers, one route. `edits` is the generated form — pointers and literals,
// type-preserving — and `text` is the raw document. A number travels as TEXT
// in both: `1.0` parsed into a JavaScript number and sent back as `1` is the
// whole failure this feature was designed around.
save: (serverId, body) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file`, { method: 'POST', body }),
writes: (serverId, limit = null) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`),
}
// ── the admin.users.detail extension slot ─────────────────────────────────
//
// The client half of R13's first slot. Core hands the component a `userId` and
@@ -189,6 +217,7 @@ export default {
playerLinks,
admin,
adminPermissions,
adminConfig,
adminUserLinks,
adminUserPermissions,
BASE,

View File

@@ -22,9 +22,10 @@ import Servers from './routes/public/Servers.jsx'
import ServerDetail from './routes/public/ServerDetail.jsx'
import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
import { IconKey, IconLink } from './icons.jsx'
import { IconKey, IconLink, IconSliders } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -82,7 +83,18 @@ registry.registerRoutes(ID, {
{ path: 'servers/:id', element: <ServerDetail /> },
],
player: [{ path: '', element: <Account /> }],
admin: [{ path: '', element: <Permissions /> }],
admin: [
{ path: '', element: <Permissions /> },
// Phase 7b (R18). A second admin page rather than a tab on the first: the
// permission mirror decides who may do what inside the game, and this edits
// the game host's own files. They are neighbours, not halves of one screen,
// and the nav says so with two rows.
//
// A static segment under the module's namespace, so it lands at
// `/admin/rust/config` and core's admin gate applies to it exactly as it
// does to the page above.
{ path: 'config', element: <ModConfig /> },
],
})
// ── Nav ───────────────────────────────────────────────────────────────────
@@ -127,7 +139,10 @@ registry.registerNav(ID, {
// every sidebar row, and the one without is the only text in a column of glyphs.
registry.registerNav(ID, {
area: 'admin',
items: [{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey }],
items: [
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
],
})
// ── Extension slots ───────────────────────────────────────────────────────

View File

@@ -62,4 +62,25 @@ export const IconKey = () => (
</Icon>
)
export default { IconLink, IconKey }
/**
* Sliders — the admin sidebar's row for the mod-configuration editor.
*
* Not a gear: core's account row is a gear, and two gears in one sidebar say
* "settings" twice without saying whose. Sliders read as values being tuned,
* which is exactly what that page does to somebody else's game host.
*/
export const IconSliders = () => (
<Icon>
<path d="M4 6h10" />
<path d="M18 6h2" />
<circle cx="16" cy="6" r="2" />
<path d="M4 12h4" />
<path d="M12 12h8" />
<circle cx="10" cy="12" r="2" />
<path d="M4 18h10" />
<path d="M18 18h2" />
<circle cx="16" cy="18" r="2" />
</Icon>
)
export default { IconLink, IconKey, IconSliders }

View File

@@ -0,0 +1,554 @@
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
//
// R18. An admin picks a server, a plugin and a file, changes something, and the
// plugin reloads. This module's second admin page, and the first that writes to
// somebody's filesystem.
//
// **What is on the screen is decided by what is dangerous about the action.**
// Four things are true here that are not true anywhere else in this module, and
// each of them is a piece of the page rather than a line in a doc:
//
// • a save can take a required plugin DOWN. So the reload target is a
// deliberate choice with the folder name as a guess, the result is reported
// as its own panel, and a rollback shows the server's own log line.
// • the form cannot express everything a config holds. A `null`, an empty
// array and anything past the depth limit are marked and sent to the raw
// tier rather than half-drawn.
// • three keys in the bridge's own config would cut the link carrying the
// edit, or split the server's history. They render read-only, with the
// reason (D38).
// • configs hold API keys and Discord webhooks. Those fields render masked
// with a reveal, which is about the shoulder rather than the wire: an admin
// can already read the file over SSH (D37), and the audit trail never
// records the values either way.
import { useCallback, useEffect, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
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 Warn({ children, tone = '#d08a2a' }) {
return (
<p className="sans" style={{ color: tone, fontSize: '0.78rem', margin: '6px 0 0' }}>
{children}
</p>
)
}
/** A value the form can edit: one row, typed by what the file already holds. */
function Field({ field, value, onChange, revealed, onReveal }) {
const indent = 12 * Math.max(0, field.depth - 1)
const label = (
<label
className="sans"
style={{
flex: '0 0 300px',
paddingLeft: indent,
color: field.locked ? 'var(--ink)' : 'var(--head)',
fontSize: '0.84rem',
wordBreak: 'break-word',
}}
title={field.path}
>
{field.key}
{field.locked && (
<span className="dim" style={{ fontSize: '0.72rem' }}>
{' '}
· read-only
</span>
)}
</label>
)
if (field.type === 'object' || field.type === 'array') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0 2px' }}>
<span
className="sans"
style={{ paddingLeft: indent, color: 'var(--head)', fontSize: '0.86rem', fontWeight: 500 }}
>
{field.key || '(the file)'}
</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{field.type === 'array' ? `${field.count} entries` : `${field.count} settings`}
{field.advanced && field.reason ? ` · ${field.reason}` : ''}
</span>
</div>
)
}
if (field.advanced) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
{label}
<span className="sans dim" style={{ fontSize: '0.78rem' }}>
{field.reason} — edit it in Raw JSON
</span>
</div>
)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
{label}
{field.type === 'boolean' ? (
<input
type="checkbox"
checked={Boolean(value)}
disabled={field.locked}
onChange={(event) => onChange(field, event.target.checked)}
/>
) : (
<input
className="input"
style={{ flex: 1, minWidth: 0 }}
type={field.secret && !revealed ? 'password' : 'text'}
value={value === undefined || value === null ? '' : String(value)}
disabled={field.locked}
onChange={(event) => onChange(field, event.target.value)}
/>
)}
{field.secret && !field.locked && (
<button type="button" className="btn btn-ghost" onClick={() => onReveal(field.path)}>
{revealed ? 'Hide' : 'Show'}
</button>
)}
</div>
)
}
/** What the game said happened. The rollback case is the one worth reading. */
function Report({ report }) {
if (!report) return null
const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)'
return (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
{report.rolledBack
? 'The plugin did not come back, so the old file was put back automatically.'
: report.reloaded
? 'Saved, and the plugin reloaded.'
: `Saved. ${report.reason || 'Nothing was reloaded.'}`}
</p>
{report.rolledBack && report.reason && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
{report.reason}
</p>
)}
{report.log && (
<pre
className="sans"
style={{
background: 'var(--line-soft)',
padding: 10,
marginTop: 8,
fontSize: '0.74rem',
maxHeight: 200,
overflow: 'auto',
whiteSpace: 'pre-wrap',
}}
>
{report.log}
</pre>
)}
{report.files.some((f) => f.rewritten) && (
<Warn>
The plugin rewrote the file as it loaded — both frameworks add any settings a config is
missing and save it back, so what is on disk now is not byte-for-byte what was sent.
</Warn>
)}
</div>
)
}
export default function ModConfig() {
const [serverId, setServerId] = useState('')
const [path, setPath] = useState('')
const [tier, setTier] = useState('form')
const [edits, setEdits] = useState({})
const [raw, setRaw] = useState('')
const [reload, setReload] = useState('')
const [revealed, setRevealed] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [report, setReport] = useState(null)
const [fileNonce, setFileNonce] = useState(0)
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
// The tree is asked for per server and never cached across one: what is on a
// host's disk has no stale answer worth showing, and a plugin loaded a minute
// ago has to be able to appear.
const { data: tree, error: treeError } = useAsync(
() => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)),
[serverId],
)
const { data: file, error: fileError } = useAsync(
() => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)),
[serverId, path, fileNonce],
)
const reset = useCallback(() => {
setEdits({})
setRevealed({})
setError('')
}, [])
// A freshly opened file starts from what the host holds: the raw editor's text
// and the reload target's guess both come from the answer rather than from
// whatever the previous file left behind.
//
// **The guess is only taken when the dropdown actually offers it.** A `<select>`
// whose value matches no `<option>` displays the first one, so a guess of
// `RunicGateway` — which is deliberately not offered, because the bridge cannot
// reload itself — put "nothing — just write the file" on the screen while the
// request carried `reload: RunicGateway`, and every save of our own config was
// refused for a reason the page had just said did not apply.
useEffect(() => {
if (!file) return
setRaw(file.text)
const offered = (tree ? tree.loaded : []).some(
(p) => p.name === file.plugin && p.name !== (tree && tree.self),
)
setReload(offered ? file.plugin : '')
reset()
}, [file, tree, reset])
useEffect(() => {
setPath('')
setReport(null)
}, [serverId])
if (serverError) return <ErrorState error={serverError} />
if (!servers) return <Loading />
const rows = servers.servers || servers || []
const change = (field, value) => setEdits((current) => ({ ...current, [field.path]: { field, value } }))
const save = async () => {
setBusy(true)
setError('')
setReport(null)
try {
const body =
tier === 'form'
? {
path,
version: file.version,
...(reload ? { reload } : {}),
// A number goes up as the TEXT that was typed. `2.50` stays
// `2.50` and `1.0` stays `1.0`; turning either into a JavaScript
// number here is precisely the bug the server half exists to
// avoid, and it would be reintroduced in the browser.
edits: Object.values(edits).map(({ field, value }) =>
field.type === 'number'
? { pointer: field.pointer, raw: String(value) }
: { pointer: field.pointer, value },
),
}
: { path, version: file.version, ...(reload ? { reload } : {}), text: raw }
const answer = await api.adminConfig.save(serverId, body)
setReport(answer.report || null)
if (!answer.changed) setError('Nothing changed, so nothing was written.')
// Re-read either way: a successful reload usually rewrites the file with
// the defaults it was missing, and a rollback means what is on disk is no
// longer what is on the screen.
setFileNonce((n) => n + 1)
} catch (err) {
setError(err.message || 'That save did not work.')
} finally {
setBusy(false)
}
}
const pending = Object.keys(edits).length
return (
<div style={{ maxWidth: 980 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
These are the configuration files on the game host itself, read live through the bridge. A
save backs the file up, writes it, reloads the plugin you name, and <strong>puts the old
file back automatically</strong> if the plugin does not come back. The game’s data
directory — kit cooldowns, zone definitions, the permission store — is not settings and is
never listed here.
</p>
<Card title="Server" subtitle={`${rows.length} configured`}>
<select className="input" value={serverId} onChange={(event) => setServerId(event.target.value)}>
<option value="">Choose a server…</option>
{rows.map((row) => (
<option key={row.id} value={row.id}>
{row.name || row.id}
</option>
))}
</select>
{tree && tree.root && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
{tree.root}
{tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''}
</p>
)}
</Card>
{serverId && treeError && <ErrorState error={treeError} />}
{serverId && !treeError && !tree && <Loading />}
{tree && (
<Card title="Files" subtitle="grouped by the plugin each one probably belongs to">
{tree.plugins.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
This server reports no configuration files.
</p>
)}
{tree.plugins.map((group) => (
<div key={group.plugin} style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<strong className="sans" style={{ fontSize: '0.88rem', fontWeight: 500 }}>
{group.title || group.plugin}
</strong>
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{group.loaded ? `loaded · ${group.version}` : 'not loaded'}
{group.isBridge ? ' · this bridge' : ''}
</span>
</div>
{group.files.map((entry) => (
<div
key={entry.path}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0 4px 12px' }}
>
<button
type="button"
className={entry.path === path ? 'btn btn-primary' : 'btn btn-ghost'}
disabled={!entry.editable}
onClick={() => {
setPath(entry.path)
setReport(null)
setTier('form')
}}
>
{entry.path}
</button>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{Math.round(entry.bytes / 102.4) / 10} KB
{entry.modified ? ` · changed ${ago(entry.modified)}` : ''}
{entry.reason ? ` · ${entry.reason}` : ''}
</span>
</div>
))}
{!group.loaded && (
<Warn>
Nothing on this server is loaded under that name, so a save here is written and
not reloaded. It applies the next time the plugin loads.
</Warn>
)}
</div>
))}
</Card>
)}
{path && fileError && <ErrorState error={fileError} />}
{path && !fileError && !file && <Loading />}
{file && (
<Card
title={file.path}
subtitle={tier === 'form' ? `${pending} unsaved` : 'raw JSON'}
actions={
<>
<button
type="button"
className={tier === 'form' ? 'btn btn-primary' : 'btn btn-ghost'}
onClick={() => setTier('form')}
>
Settings
</button>
<button
type="button"
className={tier === 'raw' ? 'btn btn-primary' : 'btn btn-ghost'}
onClick={() => setTier('raw')}
>
Raw JSON
</button>
</>
}
>
{file.parseError && (
<Warn tone="#e05a5a">
This file is not valid JSON on the server ({file.parseError}), so there is nothing to
draw a form from. Raw JSON is the tier that can fix it.
</Warn>
)}
{file.isBridge && (
<Warn>
This is the bridge’s own configuration. Its address, port and server id are read-only
here — changing any of them from the website would cut the link carrying the change,
or strand every row this site holds for this server. They are editable on the host
itself. This plugin also cannot be reloaded from here.
</Warn>
)}
{tier === 'form' && file.fields && (
<div style={{ marginTop: 6 }}>
{file.fields
.filter((field) => field.path !== '')
.map((field) => (
<Field
key={field.path}
field={field}
value={
edits[field.path]
? edits[field.path].value
: field.type === 'number'
? field.raw
: field.value
}
onChange={change}
revealed={Boolean(revealed[field.path])}
onReveal={(p) => setRevealed((current) => ({ ...current, [p]: !current[p] }))}
/>
))}
</div>
)}
{tier === 'raw' && (
<textarea
className="input"
spellCheck={false}
value={raw}
onChange={(event) => setRaw(event.target.value)}
style={{ width: '100%', minHeight: 360, fontFamily: 'monospace', fontSize: '0.8rem' }}
/>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginTop: 12,
borderTop: '1px solid var(--line-soft)',
paddingTop: 12,
}}
>
<label className="sans dim" style={{ fontSize: '0.78rem' }}>
Reload
</label>
{/* A guess, and it says so. The folder a config sits in is convention
rather than contract, so reloading it silently is how the wrong
plugin gets reloaded, reports success, and the edited one never
re-reads anything. */}
<select className="input" value={reload} onChange={(event) => setReload(event.target.value)}>
<option value="">nothing — just write the file</option>
{(tree ? tree.loaded : [])
.filter((p) => p.name !== tree.self)
.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
<span style={{ flex: 1 }} />
<button
type="button"
className="btn btn-primary"
disabled={busy || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
onClick={save}
>
{busy ? 'Saving…' : 'Save and reload'}
</button>
</div>
{/* Beside the button, not at the top of the page. A save is made at the
bottom of a long form, and a refusal rendered above the fold is a
click that visibly did nothing. */}
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.82rem', margin: '8px 0 0' }}>
{error}
</p>
)}
<Report report={report} />
</Card>
)}
{serverId && <History serverId={serverId} nonce={fileNonce} />}
</div>
)
}
/** Who changed what, including the saves that were refused or undone. */
function History({ serverId, nonce }) {
const { data } = useAsync(() => api.adminConfig.writes(serverId), [serverId, nonce])
if (!data || !data.writes || data.writes.length === 0) return null
return (
<Card title="Recent changes" subtitle="every save, including the ones that did not land">
{data.writes.map((row) => (
<div
key={row.id}
className="sans"
style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)', fontSize: '0.82rem' }}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
<strong style={{ fontWeight: 500 }}>{row.path}</strong>
<span
className="sans"
style={{ fontSize: '0.74rem', color: row.outcome === 'applied' ? 'var(--ink)' : '#d08a2a' }}
>
{row.outcome}
{row.reloaded ? ' · reloaded' : ''}
</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{ago(row.createdAt)}
{row.tier === 'raw' ? ' · raw' : ''}
</span>
</div>
{(row.changes || []).map((change, index) => (
<div key={`${row.id}-${index}`} className="dim" style={{ fontSize: '0.74rem' }}>
{change.path}
{change.from !== null && change.to !== null ? `: ${change.from} → ${change.to}` : ''}
</div>
))}
{row.detail && (
<div className="dim" style={{ fontSize: '0.74rem' }}>
{row.detail}
</div>
)}
</div>
))}
</Card>
)
}

View File

@@ -114,7 +114,7 @@ function ServerState({ row, onSync, busy }) {
{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}>
<button type="button" className="btn btn-ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
{busy ? 'Syncing…' : 'Sync now'}
</button>
</div>
@@ -158,10 +158,10 @@ function DriftRow({ row, onAdopt, onRevoke, busy }) {
{row.serverId} · seen {ago(row.firstSeen)}
</span>
</span>
<button type="button" className="btn ghost" onClick={() => onAdopt(row)} disabled={busy}>
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
Adopt
</button>
<button type="button" className="btn ghost" onClick={() => onRevoke(row)} disabled={busy}>
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
Revoke
</button>
</Row>
@@ -224,7 +224,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
actions={
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
>
@@ -248,7 +248,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
)}
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => save(group.permissions.filter((p) => p !== perm))}
>
@@ -317,7 +317,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
</span>
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
>
@@ -415,7 +415,7 @@ export default function Permissions() {
title="Servers"
subtitle={`${servers.length} configured`}
actions={
<button type="button" className="btn ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
Sync all
</button>
}
@@ -490,7 +490,7 @@ export default function Permissions() {
</span>
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
>

View File

@@ -83,7 +83,7 @@ function LinkPanel({ userId, link, onRemoved }) {
</div>
)}
</div>
<button type="button" className="btn ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
<button type="button" className="btn btn-ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
</div>
@@ -180,7 +180,7 @@ function PermissionsPanel({ userId, data, onChanged }) {
</span>
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
style={{ flex: 'none' }}

View File

@@ -122,7 +122,7 @@ function LinkRow({ link, onRemoved }) {
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
)}
</div>
<button type="button" className="btn ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
<button type="button" className="btn btn-ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
</li>