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:
2026-09-21 18:28:32 -05:00
parent a1b6d155a1
commit 43147b796a
27 changed files with 5515 additions and 21 deletions

View 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>
)
}

View File

@@ -113,11 +113,131 @@ function LinkPanel({ userId, link, onRemoved }) {
)
}
/**
* 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 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
@@ -125,7 +245,15 @@ export default function UserRustSections({ userId }) {
// 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.
if (!data || data.links.length === 0) return null
// **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 }}>
@@ -135,13 +263,22 @@ export default function UserRustSections({ userId }) {
{data.links.map((link) => (
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
))}
</div>
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '12px 0 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>
{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>
)
}