The 35 files behind twelve public pages, seven admin views, two player views and three core-page extensions, ported onto `window.__rg`. Every one of them imports exactly the seven kit members plus `lib/format.js`, which is the finding §2.7.1 predicted and this confirms. `client/src/core.js` is the port mechanism, and unlike the server's it is a plain read: `window.__rg` is published before any module chunk evaluates, so there is no gap to defer around and a ported component keeps its ordinary import shape. `client/src/api.js` rebuilds the UO namespaces over the request primitive — same URLs, because §1.2 freezes the API surface. SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a clean break being the only moment that is free. `shim/rg.js` becomes the single reader of the global, so the "core did not publish its dependencies" message is reachable from whichever module the bundler happens to touch first rather than from whichever one is imported first — a guarantee that used to last until someone sorted the imports. Co-Authored-By: Claude <noreply@anthropic.com>
89 lines
4.2 KiB
JavaScript
89 lines
4.2 KiB
JavaScript
import { useState } from 'react'
|
|
import api from '../api.js'
|
|
import { useAuth } from '../core.js'
|
|
|
|
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
|
|
// game account. Reused wherever a linked account or character is shown to staff:
|
|
// the admin user-detail account list and the character sheet. Self-gates on role
|
|
// (admin/moderator) so it is safe to render inside components that players also
|
|
// see — a player never gets the controls, and the API enforces the same gate.
|
|
//
|
|
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
|
|
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
|
|
// confirm with an optional duration + reason before it fires.
|
|
export default function ShardAccountActions({ account, style }) {
|
|
const { user } = useAuth()
|
|
const [busy, setBusy] = useState('')
|
|
const [ok, setOk] = useState('')
|
|
const [err, setErr] = useState('')
|
|
const [banOpen, setBanOpen] = useState(false)
|
|
const [durationSec, setDurationSec] = useState('')
|
|
const [reason, setReason] = useState('')
|
|
|
|
// Only staff who can actually use the write plane see the controls.
|
|
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
|
|
|
|
async function run(label, fn, done) {
|
|
setBusy(label); setOk(''); setErr('')
|
|
try {
|
|
const r = await fn()
|
|
setOk(done(r))
|
|
} catch (e) {
|
|
setErr(e.message || 'Action failed.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
const kick = () =>
|
|
run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
|
|
const n = r && r.sessions != null ? r.sessions : null
|
|
const plural = n === 1 ? '' : 's'
|
|
const sessions = n != null ? ` (${n} session${plural})` : ''
|
|
return `Kicked${sessions}.`
|
|
})
|
|
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
|
|
const ban = () =>
|
|
run('ban', () =>
|
|
api.admin.shardOps.ban({
|
|
account,
|
|
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
|
reason: reason.trim() || undefined,
|
|
}),
|
|
() => {
|
|
setBanOpen(false)
|
|
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
|
return `Banned${when}.`
|
|
})
|
|
|
|
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
|
|
|
|
return (
|
|
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
|
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
|
|
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban…</button>
|
|
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
|
|
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
|
|
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
|
</div>
|
|
|
|
{banOpen && (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
|
|
<label style={{ display: 'block' }}>
|
|
<span className="field-label">Duration (sec, blank = permanent)</span>
|
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
|
|
</label>
|
|
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
|
|
<span className="field-label">Reason (optional)</span>
|
|
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
|
</label>
|
|
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
|
|
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|