Files
Module-uo/client/src/routes/admin/ShardAdmin.jsx
wtclaude 20e4617e6f
All checks were successful
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 56s
PR Checks / frozen-manifest (pull_request) Successful in 58s
fix(shard-admin): say the pending unlink will be retried, not that it was
"Still linked on the shard" said "tried 1 time, and tried again when the
bridge reconnects" about a retry that has not happened yet. It now reads
"tried 1 time. It tries again when the bridge reconnects." Found in the
account deletion walk (docs/website/ACCOUNT_DELETION.md step 6, W2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 03:57:36 -05:00

359 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe, kindLabel } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
import { ErrorState, Loading } from '../../core.js'
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
// cheat detection and login attempts that the public channel never carries.
function AdminLiveFeed() {
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
{events.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
// uo-link sidecar control panel. The auth token is write-only over this API —
// stored encrypted, never returned — same convention as the Discord bot token.
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
// effect immediately with no redeploy.
function Toggle({ checked, onChange, label }) {
return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
const STATUS_COLOR = {
connected: '#7fd0a4',
reconnecting: '#e0b070',
error: '#d98b84',
disconnected: 'var(--muted)',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
const ingest = config.ingest || {}
const health = config.health || {}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
{config.status || 'disconnected'}
</span>
</div>
{config.statusDetail && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
)}
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
</div>
</div>
)
}
// ── Unlinks a deleted website account left behind ───────────────────────────
//
// ACCOUNT_DELETION.md §4.4. Deleting a website account unlinks its game accounts
// on the shard first; what the shard could not take is listed here until it
// clears. `pending` is retried by itself when the bridge reconnects. `refused` is
// a protected staff account the shard will not unlink over the bridge, so the
// shard's staff clear its WebsiteUserId tag by hand. A row goes on its own once a
// login on that account no longer carries the deleted user's id.
function PendingUnlinks({ rows }) {
if (!rows || rows.length === 0) return null
return (
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Still linked on the shard</h3>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
Game accounts whose website account was deleted, but whose link the shard has not cleared yet.
</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((r) => (
<li key={r.account} className="sans" style={{ fontSize: '0.85rem', color: 'var(--ink)' }}>
<strong>{r.account}</strong>
<span className="dim"> · deleted user #{r.webUserId} · {new Date(r.queuedAt).toLocaleString()}</span>
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{r.state === 'refused'
? 'Protected staff account: the shard will not unlink it over the bridge. Clear its link on the shard by hand.'
: `Waiting for the shard; tried ${r.attempts} time${r.attempts === 1 ? '' : 's'}. It tries again when the bridge reconnects.`}
</div>
</li>
))}
</ul>
</section>
)
}
// ── Game-account signup ─────────────────────────────────────────────────────
//
// This field lived in core's Site Settings until slice 3 of the extraction. It
// moved here rather than being deleted or left behind, because its help text has
// always described an agreement between this site and a ServUO shard — and half
// of that agreement is configured in Bridge.cfg, which core has never heard of.
//
// The setting key and value are unchanged (`game_account_signup`), so an
// instance that had this configured finds it here, set to what it was.
const SIGNUP_MODES = [
{ value: 'disabled', label: 'Disabled — link an existing account only' },
{ value: 'website', label: 'Website — the site creates game accounts' },
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
{ value: 'game', label: 'Game only — created in the game client, not the site' },
]
function GameSignup() {
const [mode, setMode] = useState(null)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
useEffect(() => {
let active = true
api.admin.getSignupMode()
.then((r) => active && setMode(r.mode))
.catch(() => active && setError('Could not load the signup mode.'))
return () => { active = false }
}, [])
async function save(next) {
const previous = mode
setMode(next); setBusy(true); setMsg(''); setError('')
try {
await api.admin.saveSignupMode(next)
setMsg('Saved.')
} catch (err) {
setMode(previous) // the select must not show a mode the server did not take
setError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Game-account creation</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
Whether players can create a GAME account (for the game client) from the site. The game server’s own
SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.
When enabled, a “Create a game account” form appears in the player portal and after an invite is accepted.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Mode</span>
<select
value={mode ?? ''}
onChange={(e) => save(e.target.value)}
disabled={busy || mode === null}
className="input"
style={{ maxWidth: 420 }}
>
{mode === null && <option value="">Loading…</option>}
{SIGNUP_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', minHeight: 20 }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</section>
)
}
// ── Town crier ──────────────────────────────────────────────────────────────
function TownCrier() {
const [id, setId] = useState('')
const [text, setText] = useState('')
const [durationSec, setDurationSec] = useState(3600)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function post() {
setBusy(true); setMsg(''); setError('')
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
if (!id.trim() || lines.length === 0) {
setBusy(false)
return setError('An id and at least one line are required.')
}
try {
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
setMsg(`Posted “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not post.')
} finally {
setBusy(false)
}
}
async function remove() {
if (!id.trim()) return setError('Enter the id to remove.')
setBusy(true); setMsg(''); setError('')
try {
await api.admin.deleteTownCrier(id.trim())
setMsg(`Removed “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message id</span>
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Lines (one per line)</span>
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (seconds)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</section>
)
}
export default function ShardAdmin() {
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [wsUrl, setWsUrl] = useState('')
const [token, setToken] = useState('')
const [protocol, setProtocol] = useState(3)
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [saveError, setSaveError] = useState('')
const pollRef = useRef(null)
const initializedRef = useRef(false)
const load = useCallback(async () => {
try {
const c = await api.admin.getUoLinkConfig()
setConfig(c)
// Seed the editable fields once; later polls only refresh the status panel
// so they never clobber what the admin is mid-typing.
if (!initializedRef.current) {
setBaseUrl(c.baseUrl || '')
setWsUrl(c.wsUrl || '')
setProtocol(c.protocol || 3)
setEnabled(c.enabled)
initializedRef.current = true
}
} catch {
setError('Could not load uo-link config.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 5000)
return () => clearInterval(pollRef.current)
}, [load])
async function save() {
setBusy(true); setMsg(''); setSaveError('')
try {
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
if (token) body.token = token
const saved = await api.admin.saveUoLinkConfig(body)
setConfig(saved)
setToken('')
setMsg('Saved.')
} catch (err) {
setSaveError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
if (error) return <ErrorState message={error} />
if (!config) return <Loading />
return (
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
<StatusPanel config={config} />
<PendingUnlinks rows={config.pendingUnlinks} />
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
<label style={{ display: 'block' }}>
<span className="field-label">Base URL (REST)</span>
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">WebSocket URL (feed)</span>
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Auth token</span>
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Protocol</span>
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
</div>
<GameSignup />
<TownCrier />
<AdminLiveFeed />
</section>
)
}