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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user