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
285 lines
11 KiB
JavaScript
285 lines
11 KiB
JavaScript
// ── This module's fill for `admin.users.detail` ───────────────────────────
|
|
//
|
|
// R13's first slot, and the phase criterion as an operator meets it: the Steam
|
|
// id inside core's own user page, under core's own security panel.
|
|
//
|
|
// **The slot hands over `userId` and nothing else** — not a client. So this file
|
|
// builds its own bindings for the routes the server half registered
|
|
// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of
|
|
// a call belong to the same module even when the URL between them is core's.
|
|
//
|
|
// **Most users have no Rust account, so most of the time this renders nothing.**
|
|
// A panel that announced "no linked Steam accounts" on every user page in a
|
|
// community that also runs a UO shard would be noise on the overwhelming
|
|
// majority of them. Silence is the honest answer to "what does the Rust module
|
|
// know about this person" when it is nothing.
|
|
|
|
import { useCallback, useState } from 'react'
|
|
import { ago, count, duration } from '../../lib/format.js'
|
|
import { useAsync } from '../../core.js'
|
|
import api from '../../api.js'
|
|
|
|
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
|
|
function SectionTitle({ children }) {
|
|
return (
|
|
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** One server's all-time totals for this player. */
|
|
function ServerRow({ server }) {
|
|
return (
|
|
<li
|
|
className="sans"
|
|
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.86rem', color: 'var(--ink)' }}
|
|
>
|
|
<span style={{ minWidth: 0, color: 'var(--head)' }}>{server.serverName}</span>
|
|
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
|
{count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)}
|
|
{server.wipes > 1 ? ` · ${server.wipes} wipes` : ''}
|
|
</span>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/** One linked Steam account: who it is, when it was linked, and the way out. */
|
|
function LinkPanel({ userId, link, onRemoved }) {
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
async function unlink() {
|
|
setBusy(true)
|
|
setError('')
|
|
try {
|
|
await api.adminUserLinks.remove(userId, link.steamId)
|
|
await onRemoved()
|
|
} catch (err) {
|
|
setError(err.message || 'Could not unlink that account.')
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="panel" style={{ padding: '14px 16px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
|
{link.name || link.steamId}
|
|
</div>
|
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
|
{link.steamId} · linked {ago(link.linkedAt)}
|
|
{link.serverId ? ` on ${link.serverId}` : ''}
|
|
{link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'}
|
|
</div>
|
|
{/* Worth showing only when they differ: the name on the link is what
|
|
they were called when they linked, the other is what the game last
|
|
saw. A rename is the ordinary reason, and an operator reading a
|
|
support ticket wants both names. */}
|
|
{link.linkedName && link.name && link.linkedName !== link.name && (
|
|
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
|
Linked as “{link.linkedName}”.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button type="button" className="btn btn-ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
|
|
{busy ? 'Unlinking…' : 'Unlink'}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>
|
|
)}
|
|
|
|
{link.servers.length > 0 && (
|
|
<ul
|
|
style={{
|
|
listStyle: 'none',
|
|
margin: '12px 0 0',
|
|
padding: '12px 0 0',
|
|
borderTop: '1px solid var(--line-soft)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 6,
|
|
}}
|
|
>
|
|
{link.servers.map((server) => (
|
|
<ServerRow key={server.serverId} server={server} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Phase 7's half of the panel: what this person may do in game.
|
|
*
|
|
* It renders whenever they hold anything, INCLUDING when they have linked no
|
|
* Steam account — which is the one case worth going out of the way for. A grant
|
|
* against an unlinked person is authored, stored, pushed nowhere, and identical
|
|
* to a working one everywhere except here.
|
|
*/
|
|
function PermissionsPanel({ userId, data, onChanged }) {
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = 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)
|
|
}
|
|
}
|
|
|
|
if (!data) return null
|
|
|
|
const nothing = data.groups.length === 0 && data.grants.length === 0
|
|
|
|
return (
|
|
<div className="panel" style={{ padding: '14px 16px' }}>
|
|
<div className="field-label" style={{ marginBottom: 8 }}>
|
|
Permissions
|
|
</div>
|
|
|
|
{nothing && (
|
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
|
Nothing granted.
|
|
</p>
|
|
)}
|
|
|
|
{data.groups.map((group) => (
|
|
<div key={group.name} className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
|
<span style={{ color: 'var(--head)' }}>{group.title || group.name}</span>{' '}
|
|
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
|
group · {group.scope === '*' ? 'every server' : group.scope}
|
|
{group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
|
|
{data.grants.map((row) => (
|
|
<div
|
|
key={row.id}
|
|
className="sans"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem', padding: '4px 0' }}
|
|
>
|
|
<span style={{ flex: 1, color: 'var(--head)' }}>
|
|
{row.permission}{' '}
|
|
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
|
{row.scope === '*' ? 'every server' : row.scope}
|
|
{row.source !== 'admin' ? ` · ${row.source}` : ''}
|
|
</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
disabled={busy}
|
|
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
|
|
style={{ flex: 'none' }}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
{!nothing && data.reaches.length === 0 && (
|
|
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
|
This account has linked no Steam id, so none of it reaches a game yet. It will apply by
|
|
itself when they link.
|
|
</p>
|
|
)}
|
|
|
|
<form
|
|
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
if (!permission.trim()) return
|
|
act(() =>
|
|
api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }),
|
|
)
|
|
setPermission('')
|
|
}}
|
|
>
|
|
<input
|
|
className="input"
|
|
placeholder="kits.vip"
|
|
value={permission}
|
|
onChange={(event) => setPermission(event.target.value)}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<button type="submit" className="btn" disabled={busy}>
|
|
Grant
|
|
</button>
|
|
</form>
|
|
|
|
{error && (
|
|
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function UserRustSections({ userId }) {
|
|
// Core's `useAsync` has no refresh, so a counter in the deps is how this
|
|
// re-reads after its own write (the same shape the player page uses).
|
|
const [reloads, setReloads] = useState(0)
|
|
const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads])
|
|
const { data: permissions } = useAsync(
|
|
() => api.adminUserPermissions.list(userId),
|
|
[userId, reloads],
|
|
)
|
|
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
|
|
|
// No `Loading` and no `ErrorState`, deliberately. This is a section inside
|
|
// somebody else's page: a spinner on every user page for a module most users
|
|
// have nothing to do with is worse than a section that appears when it has
|
|
// something, and a failure here must not replace core's own user detail with an
|
|
// error card.
|
|
// **Both reads decide whether this section exists**, and the second one is the
|
|
// reason. A browser walk found it: a person can hold permissions and have
|
|
// linked no Steam account — which is exactly the state an operator most needs
|
|
// to see, because it is the one that reaches nobody — and a section gated on
|
|
// links alone hides it completely.
|
|
const holdsSomething =
|
|
permissions && (permissions.groups.length > 0 || permissions.grants.length > 0)
|
|
|
|
if (!data || (data.links.length === 0 && !holdsSomething)) return null
|
|
|
|
return (
|
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
|
<SectionTitle>Rust</SectionTitle>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{data.links.map((link) => (
|
|
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
|
|
))}
|
|
|
|
{data.links.length > 0 && (
|
|
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
|
A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
|
|
recorded in the activity log — it is the way back for a player who linked the wrong
|
|
account and cannot reach it in game.
|
|
</p>
|
|
)}
|
|
|
|
{/* Inside the same section rather than beside it: "who is this in game"
|
|
and "what may they do there" are one question asked twice, and an
|
|
operator reading a support ticket has both in front of them. The note
|
|
above belongs to the links, so it sits with them rather than under
|
|
the panel it would otherwise appear to describe. */}
|
|
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|