diff --git a/.env.example b/.env.example index 3a711a0..49682ec 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,15 @@ CLIENT_ORIGIN=http://localhost:5173 # longer rides the public listener, but an explicit deny rule is belt-and-braces. BOT_INTERNAL_URL=http://bot:4100 BOT_INTERNAL_KEY=change-me-to-a-long-random-string + +# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The +# website ingests its live event feed and proxies its read queries/commands +# (shard status, online players, player-vendor sales, IDOC houses, character +# sheets, account linking, town-crier). In production the sidecar + shard run on +# a DIFFERENT host from the website, so both URLs are configurable. The +# shared-secret auth token is NOT an env var — it is entered in the admin panel +# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot +# token). These URLs are just defaults; the admin can override them at runtime. +UOLINK_BASE_URL=http://127.0.0.1:8080 +UOLINK_WS_URL=ws://127.0.0.1:8080/ws +UOLINK_PROTOCOL=1 diff --git a/README.md b/README.md index 5887e73..a22b14a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ shard — a full-stack app in one repo: - **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO). - **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia). - **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production. +- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([UOM/link](https://gitea.whitlocktech.com/UOM/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link). The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security). @@ -24,6 +25,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc - [Pages & routes](#pages--routes) - [API endpoints](#api-endpoints) - [API documentation (Swagger)](#api-documentation-swagger) +- [Shard integration (uo-link)](#shard-integration-uo-link) - [Environment variables](#environment-variables) - [Security](#security) - [Logging](#logging) @@ -207,6 +209,9 @@ npm start # node server → serves API + SPA at http://localhost:3 | SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow | | Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none | | Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) | +| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none | +| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) | +| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) | Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`. `authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`. @@ -251,6 +256,74 @@ not crash). --- +## Shard integration (uo-link) + +The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that +runs next to the ServUO shard. Its source lives in a separate repo: +**[UOM/link](https://gitea.whitlocktech.com/UOM/link)**. uo-link speaks the shard's internals and +exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard +itself is never exposed to the internet — only the sidecar is, and only the website's backend talks +to it. + +### How it works + +``` +ServUO shard ──▶ uo-link sidecar (UOM/link) ──▶ website backend ──▶ browser + REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE +``` + +- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret + token, and protocol version are stored in the database (`uoLinkConfig`), edited from the + **Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in + the API — it is never returned to any client and never sent to the browser. Every call the backend + makes carries `Authorization: Bearer ` and an `X-UOLink-Version` header (a protocol + mismatch fails fast with `409` instead of being mis-parsed). +- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar + and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`, + `vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`, + `link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes + each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable + kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply + ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a + restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via + the sidecar's `/history`. +- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly — + `/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus + commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never + throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart + degrades to a `503`/retry banner instead of a 500. +- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**. + Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an + **admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login + attempts, IPs). Sensitive kinds can never leak onto the public channel. + +### Account linking + +A player (or staff member) proves ownership of a game account without sharing any game credentials: + +1. In game, the player runs **`[link`** and receives a one-time code. +2. On the website (Player portal, or Admin → Account for staff) they enter the code. +3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the + game account with the website user id, and mirrors the link locally in `shard_account_links`. + +That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints +are **ownership-checked** so a user only sees accounts they linked. **Admins may view any +character**; players and editor/moderator staff are limited to their own linked accounts. + +### What each audience sees + +| Surface | Endpoints | Who | Data | +|---|---|---|---| +| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. | +| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. | +| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). | + +The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the +shard is offline), every shard surface degrades gracefully — the public page still renders, showing +the shard as offline. + +--- + ## Environment variables Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.env` is git-ignored.** diff --git a/client/src/App.jsx b/client/src/App.jsx index 99a54dc..56caeee 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -16,6 +16,8 @@ import Newsletter from './routes/public/Newsletter.jsx' import NewsletterIssue from './routes/public/NewsletterIssue.jsx' import About from './routes/public/About.jsx' import Status from './routes/public/Status.jsx' +import Shard from './routes/public/Shard.jsx' +import ShardActivity from './routes/public/ShardActivity.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -33,6 +35,9 @@ import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' +import ShardAdmin from './routes/admin/views/ShardAdmin.jsx' +import AdminCharacters from './routes/admin/views/AdminCharacters.jsx' +import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' @@ -42,6 +47,9 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx' // Player portal import PlayerLogin from './routes/player/PlayerLogin.jsx' import PlayerRegister from './routes/player/PlayerRegister.jsx' +import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx' +import PlayerCharacters from './routes/player/PlayerCharacters.jsx' +import PlayerCharacter from './routes/player/PlayerCharacter.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx' export default function App() { @@ -66,6 +74,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> {/* CMS pages: top-level /:slug, matched only after the named routes @@ -109,6 +119,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> @@ -119,13 +132,16 @@ export default function App() { } /> } /> - + } - /> + > + } /> + } /> + } /> + } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 3357c1a..a880c5f 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -79,6 +79,28 @@ export const api = { pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`), contact: (payload) => req('/public/contact', { method: 'POST', body: payload }), + // ----- shard live data (uo-link) ----- + // Token-free, same-origin reads backed by the ingested feed + a cached live + // character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed. + shard: { + status: () => req('/public/shard/status'), + feed: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.kind) qs.set('kind', opts.kind) + if (opts.limit) qs.set('limit', opts.limit) + const s = qs.toString() + return req(`/public/shard/feed${s ? `?${s}` : ''}`) + }, + economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`), + online: () => req('/public/shard/online'), + idoc: () => req('/public/shard/idoc'), + }, + // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is + // fetch-only, so SSE subscribers build the URL from here. The admin stream + // carries every kind (incl. audit/cheat) and needs the staff session cookie. + shardStreamUrl: `${BASE}/public/shard/stream`, + adminShardStreamUrl: `${BASE}/admin/uo-link/stream`, + // ----- admin ----- admin: { dashboard: () => req('/admin/dashboard'), @@ -193,6 +215,16 @@ export const api = { linkedIdentities: () => req('/admin/account/identities'), unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }), + // ----- game account linking (self-service, staff) ----- + shard: { + link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }), + accounts: () => req('/admin/shard/accounts'), + roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`), + vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`), + char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`), + sales: () => req('/admin/shard/sales'), + }, + // ----- auth providers / SSO config (admin only) ----- listAuthProviders: () => req('/admin/auth/providers'), createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }), @@ -203,6 +235,12 @@ export const api = { getDiscordBotConfig: () => req('/admin/discord-bot/config'), saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }), + // ----- uo-link sidecar control (admin only) ----- + getUoLinkConfig: () => req('/admin/uo-link/config'), + saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }), + postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }), + deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), + // ----- Email delivery / Gmail OAuth2 (admin only) ----- getEmailConfig: () => req('/admin/email/config'), saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }), @@ -225,6 +263,16 @@ export const api = { totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }), linkedIdentities: () => req('/player/account/identities'), unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }), + + // ----- game account linking (uo-link) ----- + shard: { + link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }), + accounts: () => req('/player/shard/accounts'), + roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`), + vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), + char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`), + sales: () => req('/player/shard/sales'), + }, }, } diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx new file mode 100644 index 0000000..34bdb9e --- /dev/null +++ b/client/src/components/CharacterSheet.jsx @@ -0,0 +1,141 @@ +// Reusable character-sheet renderer for the char.profile shape returned by +// /public/shard/char/:serial. Presentational only — the parent handles loading +// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles). + +const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' } + +function StatTile({ value, label }) { + return ( +
+
{value}
+
{label}
+
+ ) +} + +function Vital({ label, cur, max }) { + const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0 + return ( +
+
+ {label} + {cur ?? '—'} / {max ?? '—'} +
+
+
+
+
+ ) +} + +export default function CharacterSheet({ char }) { + if (!char) return null + const stats = char.stats || {} + const resist = stats.resist || {} + // Skills the character actually has, best first. + const skills = (char.skills || []) + .filter((s) => (s.value || s.base || 0) > 0) + .sort((a, b) => (b.value || 0) - (a.value || 0)) + const equipment = char.equipment || [] + + return ( +
+ {/* Identity */} +
+

{char.name || 'Unknown'}

+ {char.title && {char.title}} + + + {char.online ? 'Online' : 'Offline'} + + {char.serial} +
+ + {/* Core stats */} +
+
Attributes
+
+ + + +
+
+ + + +
+
+ + {/* Resistances */} + {Object.keys(resist).length > 0 && ( +
+
Resistances
+
+ {['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => ( +
+
{resist[k] ?? 0}
+
{RESIST_LABELS[k]}
+
+ ))} +
+
+ )} + + {/* Skills */} + {skills.length > 0 && ( +
+
Skills ({skills.length})
+
+ {skills.map((s) => { + const cap = s.cap || 100 + const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100)) + return ( +
+
+ {s.n} + {s.value} +
+
+
+
+
+ ) + })} +
+
+ )} + + {/* Equipment */} + {equipment.length > 0 && ( +
+
Equipment
+
+ {equipment.map((it) => ( +
+ +
+
{it.layer || 'Item'}
+
id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}
+
+ {it.mods && Object.keys(it.mods).length > 0 && ( +
+ {Object.entries(it.mods).map(([k, v]) => ( + {k} {v} + ))} +
+ )} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx new file mode 100644 index 0000000..c4617e7 --- /dev/null +++ b/client/src/components/GameAccounts.jsx @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { Loading, ErrorState } from './PageState.jsx' + +// Shared game-account linking + character roster, used by both the player portal +// (/player) and the staff account page (/admin/account). `scope` is the api +// object with { link, accounts, roster } (player or admin self-service); `charTo` +// maps a serial to the route for that character's sheet. + +function LinkForm({ scope, onLinked, compact }) { + const [code, setCode] = useState('') + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [error, setError] = useState('') + + async function submit(e) { + e.preventDefault() + setMsg(''); setError('') + if (!code.trim()) return + setBusy(true) + try { + const { account } = await scope.link(code.trim()) + setMsg(`Linked ${account}.`) + setCode('') + await onLinked() + } catch (err) { + setError(err.message || 'Could not link that code.') + } finally { + setBusy(false) + } + } + + return ( +
+ + + {msg && {msg}} + {error && {error}} +
+ ) +} + +function AccountRoster({ scope, account, charTo }) { + const [roster, setRoster] = useState(null) + const [error, setError] = useState('') + const [unavailable, setUnavailable] = useState(false) + + const load = useCallback(async () => { + setError(''); setUnavailable(false) + try { + setRoster(await scope.roster(account)) + } catch (err) { + if (err.status === 503) setUnavailable(true) + else setError(err.message || 'Could not load this account.') + } + }, [scope, account]) + useEffect(() => { load() }, [load]) + + if (unavailable) { + return ( +
+

The game server is restarting — try again shortly.

+ +
+ ) + } + if (error) return

{error}

+ if (!roster) return

Loading…

+ + const chars = roster.chars || [] + if (chars.length === 0) return

No characters on this account.

+ + return ( +
+ {chars.map((c) => ( + + + {(c.name || '?').charAt(0)} + +
+
{c.name}
+
{c.online ? 'Online' : 'Offline'}
+
+ + + ))} +
+ ) +} + +export default function GameAccounts({ scope, charTo }) { + const [accounts, setAccounts] = useState(null) + const [error, setError] = useState('') + + const load = useCallback(async () => { + setError('') + try { + setAccounts(await scope.accounts()) + } catch { + setError('Could not load your game accounts.') + } + }, [scope]) + useEffect(() => { load() }, [load]) + + if (error) return + if (!accounts) return + + // Not linked yet — prompt to link. + if (accounts.length === 0) { + return ( +
+
Link your game account
+

+ You haven’t linked a game account yet. In game, type [link to get a + one-time code, then enter it below to see your characters, stats, skills and vendors here. +

+ +
+ ) + } + + // Linked — characters grouped by account. + return ( +
+ {accounts.map((a) => ( +
+
+ {a.account} +
+ +
+ ))} +
+
Link another account
+ +
+
+ ) +} diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 2d9ae4d..e8df12f 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -1,26 +1,37 @@ -import { Link } from 'react-router-dom' +import { Link, NavLink } from 'react-router-dom' import MoonDot from './MoonDot.jsx' +import { useAuth } from '../contexts/AuthContext.jsx' -const NAV = { - website: [ - { label: 'News', to: '/site/news' }, - { label: 'Screenshots', to: '/site/screenshots' }, - { label: 'Five on Friday', to: '/site/five-on-friday' }, - { label: 'Newsletter', to: '/site/newsletter' }, - { label: 'About', to: '/site/about' }, - { label: 'Wiki', to: '/wiki' }, - ], - wiki: [ - { label: 'Website', to: '/site' }, - { label: 'New Player Guide', to: '/wiki/new-player-guide' }, - { label: 'Maps & Atlas', to: '/wiki/maps-atlas' }, - { label: 'Systems', to: '/wiki/systems' }, - { label: 'Rules', to: '/wiki/rules' }, - ], -} +// One consistent top nav for the whole public site. Every page gets the same +// main links plus an auth-aware entry on the right (Sign in / My Account / Admin). +const NAV = [ + { label: 'Home', to: '/', end: true }, + { label: 'News', to: '/site/news' }, + { label: 'Screenshots', to: '/site/screenshots' }, + { label: 'Five on Friday', to: '/site/five-on-friday' }, + { label: 'Newsletter', to: '/site/newsletter' }, + { label: 'Wiki', to: '/wiki' }, + { label: 'Shard', to: '/site/shard' }, + { label: 'About', to: '/site/about' }, +] + +const linkStyle = ({ isActive }) => ({ + background: isActive ? 'var(--accent)' : undefined, + color: isActive ? 'var(--bg-deep)' : undefined, + borderColor: isActive ? 'var(--accent)' : undefined, +}) + +export default function SiteHeader() { + const { user, loading } = useAuth() + + // Where the auth entry points: staff → admin, player → portal, else sign in. + const account = + user && user.role && user.role !== 'player' + ? { label: 'Admin', to: '/admin' } + : user + ? { label: 'My Account', to: '/player' } + : { label: 'Sign in', to: '/account/login' } -export default function SiteHeader({ section = 'website' }) { - const links = NAV[section] || NAV.website return (
UOMysticmoon
diff --git a/client/src/components/VendorSales.jsx b/client/src/components/VendorSales.jsx new file mode 100644 index 0000000..81cacb7 --- /dev/null +++ b/client/src/components/VendorSales.jsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' +import { ago } from '../lib/format.js' + +// Owner-private recent player-vendor sales. `fetchSales` is the scope method +// (api.player.shard.sales / api.admin.shard.sales) — the server only returns +// sales for accounts linked to the caller. +export default function VendorSales({ fetchSales }) { + const [sales, setSales] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + let active = true + fetchSales() + .then((rows) => active && setSales(rows)) + .catch(() => active && setError('Could not load your vendor sales.')) + return () => { active = false } + }, [fetchSales]) + + if (error) return null + if (!sales) return null + + return ( +
+
Recent vendor sales
+ {sales.length === 0 ? ( +

No vendor sales recorded yet.

+ ) : ( +
    + {sales.map((s, i) => ( +
  • + + {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp + + {ago(s.t)} +
  • + ))} +
+ )} +
+ ) +} diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js new file mode 100644 index 0000000..963cd71 --- /dev/null +++ b/client/src/lib/shardEvents.js @@ -0,0 +1,88 @@ +// Shared formatting for shard events — used by the public Shard page, the +// Activity feed, and the admin live feed. One place decides how each kind reads +// and which category/badge it belongs to. + +function nameOf(who) { + if (!who) return 'Someone' + if (typeof who === 'string') return who + return who.name || who.acct || 'Someone' +} + +const n = (v) => Number(v || 0).toLocaleString() + +// A one-line human description of an event. Accepts either a stored event +// (with .payload) or a raw live frame (fields at top level). +export function describe(ev) { + const p = ev.payload || ev + switch (ev.kind) { + case 'vendor.sale': + return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp` + case 'player.death': + return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}` + case 'player.murdered': + return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}` + case 'mob.killed': + return `${nameOf(p.killer)} killed ${nameOf(p.killed)}` + case 'skill.gain': + return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}` + case 'fame.change': + return `${nameOf(p.who)}’s fame changed to ${n(p.new)}` + case 'karma.change': + return `${nameOf(p.who)}’s karma changed to ${n(p.new)}` + case 'quest.complete': + return `${nameOf(p.who)} completed “${p.quest}”` + case 'house.decay': + return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? ` — ${p.region}` : ''}` + case 'mob.login': + return `${nameOf(p.who)} entered the world` + case 'mob.logout': + return `${nameOf(p.who)} left the world` + case 'economy.supply': + return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts` + case 'server.hello': + return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles` + case 'server.shutdown': + return 'Shard shut down' + case 'server.crashed': + return `Shard crashed${p.error ? `: ${p.error}` : ''}` + // Staff / sensitive (admin channel only) + case 'audit.set': + return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})` + case 'audit.command': + return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}` + case 'cheat.fastwalk': + return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}` + case 'account.login.attempt': + return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}` + case 'gold.change': + return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}` + default: + return ev.kind + } +} + +// Category grouping for the filter tabs. +// Vendor sales are intentionally NOT a public category — they are owner-private +// (a linked player sees their own under the portal). The admin live feed still +// describes vendor.sale via describe() below. +export const CATEGORIES = [ + { id: 'all', label: 'All', kinds: null }, + { id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] }, + { id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] }, + { id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] }, +] + +const CATEGORY_OF = (() => { + const m = {} + for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id + return m +})() + +export function categoryOf(kind) { + return CATEGORY_OF[kind] || 'other' +} + +// Short badge label for a kind (the part after the dot, title-cased-ish). +export function kindLabel(kind) { + return String(kind || '').replace(/[._]/g, ' ') +} diff --git a/client/src/lib/useShardFeed.js b/client/src/lib/useShardFeed.js new file mode 100644 index 0000000..60e9558 --- /dev/null +++ b/client/src/lib/useShardFeed.js @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../api/client.js' + +// Subscribe to the public shard live-event SSE stream and keep a rolling buffer +// of the most recent events. The browser talks to our own /public/shard/stream +// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token +// stays server-side and it works through any reverse proxy. +// +// EventSource auto-reconnects on drop, so there is no manual retry loop here; a +// `connected` flag is exposed for a small live/offline indicator. `filter` (a +// Set of kinds, optional) limits which events are buffered. `max` caps the +// buffer length. +export function useShardFeed({ url, filter, max = 40 } = {}) { + const [events, setEvents] = useState([]) + const [connected, setConnected] = useState(false) + // Keep the latest filter in a ref so re-renders don't tear down the stream. + const filterRef = useRef(filter) + filterRef.current = filter + const streamUrl = url || api.shardStreamUrl + + useEffect(() => { + // EventSource isn't available during SSR / very old browsers — degrade to + // "no live feed" rather than throwing. + if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined + + const es = new EventSource(streamUrl, { withCredentials: true }) + + es.onopen = () => setConnected(true) + es.onerror = () => setConnected(false) // EventSource will retry on its own + + es.onmessage = (msg) => { + let event + try { + event = JSON.parse(msg.data) + } catch { + return + } + if (!event || !event.kind) return + const f = filterRef.current + if (f && !f.has(event.kind)) return + setEvents((prev) => { + // Tag with a stable-ish local id for React keys (events carry t but can + // collide within a ms) and cap the buffer. + const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev] + return next.slice(0, max) + }) + } + + return () => es.close() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [max, streamUrl]) + + return { events, connected } +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 9cf3c69..30bdd32 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -37,6 +37,7 @@ const IconKey = () => const IconPulse = () => const IconUser = () => +const IconShard = () => // Nav is grouped into collapsible categories. A group with no `title` renders // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` @@ -72,11 +73,13 @@ const NAV = [ { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, { to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] }, + { to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] }, { to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] }, ], }, { items: [ + { to: '/admin/characters', label: 'My Characters', icon: IconShard }, { to: '/admin/account', label: 'Account', icon: IconUser }, ], }, @@ -95,6 +98,8 @@ const TITLES = { '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Web Bot Activity', '/admin/discord-bot': 'Discord Bot', + '/admin/shard': 'Shard (uo-link)', + '/admin/characters': 'My Characters', '/admin/auth-providers': 'Authentication', '/admin/users': 'Users', '/admin/account': 'Account Security', @@ -120,7 +125,11 @@ export default function AdminLayout() { const location = useLocation() const title = TITLES[location.pathname] || - (location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin') + (location.pathname.startsWith('/admin/moderation') + ? 'Moderation' + : location.pathname.startsWith('/admin/characters') + ? 'My Characters' + : 'Admin') // The hero canvas editor needs room — let it use the full content width. const wide = location.pathname === '/admin/hero' const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)' diff --git a/client/src/routes/admin/views/AdminCharacter.jsx b/client/src/routes/admin/views/AdminCharacter.jsx new file mode 100644 index 0000000..db54934 --- /dev/null +++ b/client/src/routes/admin/views/AdminCharacter.jsx @@ -0,0 +1,29 @@ +import { useParams, Link } from 'react-router-dom' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import CharacterSheet from '../../../components/CharacterSheet.jsx' +import { useAsync } from '../../../lib/useAsync.js' +import { api } from '../../../api/client.js' + +// A staff member's own character sheet inside the admin shell. Owner-checked — +// the endpoint only returns a sheet for a character on the caller's linked account. +export default function AdminCharacter() { + const { serial } = useParams() + const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial]) + const restarting = error && error.status === 503 + const forbidden = error && error.status === 403 + + return ( +
+

+ + ← Back to my characters + +

+ {loading && } + {restarting && } + {forbidden && } + {error && !restarting && !forbidden && } + {!loading && !error && data && } +
+ ) +} diff --git a/client/src/routes/admin/views/AdminCharacters.jsx b/client/src/routes/admin/views/AdminCharacters.jsx new file mode 100644 index 0000000..7167603 --- /dev/null +++ b/client/src/routes/admin/views/AdminCharacters.jsx @@ -0,0 +1,17 @@ +import GameAccounts from '../../../components/GameAccounts.jsx' +import VendorSales from '../../../components/VendorSales.jsx' +import { api } from '../../../api/client.js' + +// Staff link their OWN in-game account and view their characters — the same +// shared component players use, pointed at the staff self-service endpoints. +export default function AdminCharacters() { + return ( +
+

+ Link your own game account to view your characters, stats, skills and vendors. +

+ `/admin/characters/${serial}`} /> + +
+ ) +} diff --git a/client/src/routes/admin/views/ShardAdmin.jsx b/client/src/routes/admin/views/ShardAdmin.jsx new file mode 100644 index 0000000..e59a405 --- /dev/null +++ b/client/src/routes/admin/views/ShardAdmin.jsx @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useShardFeed } from '../../../lib/useShardFeed.js' +import { describe, kindLabel } from '../../../lib/shardEvents.js' +import { ago } from '../../../lib/format.js' +import { api } from '../../../api/client.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 ( +
+
+

Live feed (all events)

+ + + {connected ? 'Live' : 'Offline'} + +
+ {events.length === 0 ? ( +

Waiting for shard events…

+ ) : ( +
    + {events.map((e) => ( +
  • + {kindLabel(e.kind)} + {describe(e)} + {ago(e.t)} +
  • + ))} +
+ )} +
+ ) +} + +// 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 ( + + ) +} + +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 ( +
+
+ + + {config.status || 'disconnected'} + +
+ {config.statusDetail && ( +

{config.statusDetail}

+ )} +
+ Shard link: {config.pluginConnected ? 'up' : 'down'} + WS ingest: {ingest.connected ? 'connected' : 'offline'} + Reconnects: {ingest.reconnects ?? 0} + SSE clients: {(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)} + {config.lastEventAt && Last event: {new Date(config.lastEventAt).toLocaleString()}} + {health.uptime && Sidecar uptime: {health.uptime}} +
+
+ ) +} + +// ── 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 ( +
+

Town crier

+

+ Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it. +

+ +