From 1d1350558ba7a9a1f6948ee3f19db1c26de51b85 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 11 Aug 2026 16:45:08 -0500 Subject: [PATCH 1/2] feat(modules): client extension slots (phase 3, slice 2) The client twin of the server's declareSlot/registerExtension, and the same rule in both halves: core declares a slot, only core declares one, and at most one module fills it. Core renders and gets nothing back when the slot is unfilled, so an instance with no module installed renders exactly what it rendered before -- the same untouched-path guarantee withModuleNav makes. A slot is named for a PLACE, never for a meaning. Core supplies the position and the styling; the label, the target, the data and whether anything renders at all are the module's. The moment core types a slot by its content it has re-acquired the game semantics phase 3 exists to remove. This is the one place the client registry is not fail-open. An unknown slot, a non-component and a second fill all throw, matching checkExtensionShape server-side, because a dropped nav row costs a link the viewer can reach another way while a silently dropped extension is invisible to everyone including its author. A throw is always a programming error and never a race: core declares in its own bundle and every module chunk is a deferred script injected after it. Reading stays fail-safe -- undeclared and unfilled both read null -- and a filling component renders inside an error boundary. That asymmetry is where the client differs from the server: a module route that throws costs the module's own page, but an extension throws inside CORE's, and the whole reason core keeps ownership of that page is that it stays usable. Core decorates a slot through , not by asking whether it is filled. The obvious alternative is right about the unfilled case and wrong about the failed one -- the extension is filled, so the separator renders, and then the component throws into the boundary and leaves the separator behind on its own. wrap puts core's decoration inside the boundary where it shares the extension's fate. Found in a browser, with the footer's separator, which is the only place either could have been found. MODULE_API_VERSION 1.1.0 -> 1.2.0, both halves: the two state ONE version. Contract: docs/website/MODULE_API.md 3.7. Co-Authored-By: Claude --- client/src/modules/Slot.jsx | 68 +++++++++++++++++++++ client/src/modules/registry.js | 72 +++++++++++++++++++++++ client/src/modules/version.js | 6 +- client/test/moduleRegistry.test.js | 1 + client/test/moduleSlots.test.js | 94 ++++++++++++++++++++++++++++++ server/src/modules/version.js | 9 ++- 6 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 client/src/modules/Slot.jsx create mode 100644 client/test/moduleSlots.test.js diff --git a/client/src/modules/Slot.jsx b/client/src/modules/Slot.jsx new file mode 100644 index 0000000..1ae2d92 --- /dev/null +++ b/client/src/modules/Slot.jsx @@ -0,0 +1,68 @@ +// ── — where core renders a module's content ───────────────────────── +// +// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the normative +// contract is docs/website/MODULE_API.md §3.7. +// +// The read side of registry.js's extension slots. Core puts one of these where a +// module may contribute to a core page, and gets back either the filling +// component with the props core passed, or nothing at all. +// +// **Nothing at all is the important half.** An instance with no module installed +// renders the identical page it renders today, which is the same untouched-path +// guarantee `withModuleNav` makes for nav — and the reason a core layout can +// place a slot without also acquiring an empty-state to design. + +import React from 'react' +import { extensionFor } from './registry.js' + +/** + * Contain a module's render failure to the module's own section. + * + * This is where the client differs from the server, deliberately. A module + * *route* that throws costs the module's own page and core does not need to care. + * An extension throws inside CORE's page — the admin's user detail, the site + * footer — and the whole reason core keeps ownership of that page is that it + * stays usable. So a slot renders nothing and logs, rather than taking the + * surrounding page down with it. + * + * A class because that is what React gives us: there is no hook form of + * componentDidCatch, and this is the only error boundary core has. + */ +class SlotBoundary extends React.Component { + constructor(props) { + super(props) + this.state = { failed: false } + } + + static getDerivedStateFromError() { + return { failed: true } + } + + componentDidCatch(error) { + // Named so the console says whose fault it is: a blank section with an + // anonymous stack is how a module bug becomes core's support ticket. + console.error(`[modules] extension in slot "${this.props.name}" threw and was dropped`, error) + } + + render() { + return this.state.failed ? null : this.props.children + } +} + +/** + * @param {string} name the slot id, declared by core in main.jsx + * @param {function} [wrap] core markup that only makes sense AROUND a rendered + * extension — a separator, a heading, a rule. Called with the extension's + * element and rendered inside the boundary, so it shares the extension's fate: + * an unfilled slot and a failed one both render nothing at all, decoration + * included. Found in a browser, because the obvious alternative — asking + * whether the slot is filled and rendering the separator alongside — is right + * about the unfilled case and leaves a stray separator behind on the failed one. + * @param {object} props everything else is handed to the filling component + */ +export default function Slot({ name, wrap, ...props }) { + const Extension = extensionFor(name) + if (!Extension) return null + const element = + return {wrap ? wrap(element) : element} +} diff --git a/client/src/modules/registry.js b/client/src/modules/registry.js index bdc7892..87d7a03 100644 --- a/client/src/modules/registry.js +++ b/client/src/modules/registry.js @@ -32,6 +32,8 @@ const routes = { public: [], admin: [], player: [] } const nav = { public: [], admin: [], player: [] } const providers = new Map() +// slot name → { Component, filledBy }. +const slots = new Map() const registered = new Set() const AREAS = ['public', 'admin', 'player'] @@ -100,6 +102,70 @@ export function registerFeatureProvider(id, namespace, hook) { registered.add(id) } +// ── Extension slots (§3.7) ───────────────────────────────────────────────── +// +// The client twin of the server's declareSlot/registerExtension, and the same +// rule in both halves: core declares a slot, ONLY core declares one, and at most +// one module fills it. Core renders `` (Slot.jsx) and gets nothing +// back when the slot is unfilled — so an instance with no module installed +// renders exactly what it renders today. +// +// A slot is named for a PLACE, never for a meaning. `site.footer.status` is a +// position in the footer and the styling that goes with it; the label, the +// target, the data and whether anything renders at all are the module's. The +// moment core types a slot by its content it has re-acquired the game semantics +// this whole extraction removes. + +/** + * @param {string} name the slot id. Core-only — deliberately not on the + * `registry` object handed to modules. + */ +export function declareSlot(name) { + if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`) + slots.set(name, { Component: null, filledBy: null }) +} + +/** + * Fill a declared slot with a component. + * + * **This is the one place the client registry is not fail-open**, and the + * asymmetry is deliberate. A dropped nav row costs a link the viewer can reach + * another way; a silently dropped extension is invisible to everyone including + * its author. So an unknown slot, a non-component, and a second fill all throw — + * exactly as the server's checkExtensionShape does. + * + * A throw here is always a programming error and never a race, because + * declaration structurally precedes filling: core declares in main.jsx, inside + * its own bundle, and every module chunk is a deferred script injected after it + * (§3.1). + */ +export function registerExtension(id, slot, Component) { + const entry = slots.get(slot) + if (!entry) throw new Error(`registerExtension: unknown extension slot "${slot}"`) + if (typeof Component !== 'function') throw new Error(`registerExtension: ${slot} is not a component`) + if (entry.filledBy) throw new Error(`extension slot "${slot}" is already filled by "${entry.filledBy}"`) + entry.Component = Component + entry.filledBy = id + registered.add(id) +} + +/** + * The filling component, or null. + * + * Read by Slot.jsx and nothing else — deliberately. There is no `hasExtension` + * for a core layout to branch on, because a layout that asks whether a slot is + * filled and then renders its own decoration alongside gets the *failed* case + * wrong: the extension is filled, so the decoration renders, and the component + * then throws into the boundary leaving the decoration behind on its own. Core + * decorates through `` instead, which puts the decoration inside the + * boundary where it shares the extension's fate. (Found in a browser, with the + * footer's separator.) + * + * Undeclared and unfilled both read null: reading is fail-safe, and only writing + * is strict. + */ +export const extensionFor = (slot) => (slots.get(slot) || {}).Component || null + export const routesFor = (area) => routes[area] || [] // Sorted by the `order` a module asked for. Array#sort is stable in every engine @@ -131,6 +197,11 @@ export function _reset() { nav[area].length = 0 } providers.clear() + // Declarations go too, unlike the server's, where a slot is declared once at + // require time by the router that owns it. Core declares its slots in + // main.jsx — the one file no test loads — so on this side there is nothing + // declared at import time for a surviving declaration to protect. + slots.clear() registered.clear() } @@ -142,6 +213,7 @@ export const registry = { registerRoutes, registerNav, registerFeatureProvider, + registerExtension, routesFor, navFor, featureProviderFor, diff --git a/client/src/modules/version.js b/client/src/modules/version.js index b527fe5..466b4c9 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,9 +11,13 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.2.0 — `registry` gained `registerExtension` and core gained extension slots +// (MODULE_API.md §3.7). The first change to window.__rg since 1.0.0, and an +// addition: a module that never fills a slot is unaffected. The server half is +// untouched and bumps anyway, for the reason below. // 1.1.0 — the server's ctx gained activity.log, users.getById, site.baseUrl and // the rate-limit factory (MODULE_API.md §2.3). Nothing on window.__rg changed, // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.1.0' +export const MODULE_API_VERSION = '1.2.0' diff --git a/client/test/moduleRegistry.test.js b/client/test/moduleRegistry.test.js index 49c6153..ffd78b1 100644 --- a/client/test/moduleRegistry.test.js +++ b/client/test/moduleRegistry.test.js @@ -162,6 +162,7 @@ test('the registry object handed to modules exposes the whole surface', () => { assert.deepEqual(Object.keys(registry).sort(), [ 'featureProviderFor', 'navFor', + 'registerExtension', 'registerFeatureProvider', 'registerNav', 'registerRoutes', diff --git a/client/test/moduleSlots.test.js b/client/test/moduleSlots.test.js new file mode 100644 index 0000000..d0540e4 --- /dev/null +++ b/client/test/moduleSlots.test.js @@ -0,0 +1,94 @@ +import { test, beforeEach } from 'node:test' +import assert from 'node:assert/strict' + +import { + registry, + declareSlot, + registerExtension, + extensionFor, + registeredIds, + _reset, +} from '../src/modules/registry.js' + +// Client extension slots (docs/website/MODULE_API.md §3.7) — the client twin of +// the server's declareSlot/registerExtension. +// +// The registry half only. `` itself renders, and there is no DOM in this +// runner, so what it does with what these functions return — including the error +// boundary — is proved by the §7.7 browser smoke instead. Everything below is a +// rule that can be stated without rendering anything, and every one of them can +// be got wrong in a way a browser check would not obviously catch. + +beforeEach(() => _reset()) + +const Fake = () => null +const Other = () => null + +test('an unfilled slot reads as nothing', () => { + // The guarantee core's layouts rest on: place a slot, install no module, and + // the page renders what it rendered before. + declareSlot('site.footer.status') + assert.equal(extensionFor('site.footer.status'), null) +}) + +test('an undeclared slot reads as nothing rather than throwing', () => { + // Reading is core's side and stays fail-safe: a typo in a layout costs that + // spot, not the page. Only WRITING is strict, which is the next test. + assert.equal(extensionFor('nope'), null) +}) + +test('a module fills a declared slot and core reads it back', () => { + declareSlot('admin.users.detail') + registerExtension('uo', 'admin.users.detail', Fake) + assert.equal(extensionFor('admin.users.detail'), Fake) + assert.deepEqual(registeredIds(), ['uo']) +}) + +test('filling an unknown slot throws, naming the slot', () => { + // This is the one place the client registry is NOT fail-open, and the reason + // is asymmetry of consequence: a dropped nav row costs a link the viewer can + // reach another way, a silently dropped extension is invisible to everyone + // including its author. Declaration structurally precedes filling (§3.1), so + // this can only ever be a typo or a version skew. + assert.throws(() => registerExtension('uo', 'site.footer.sttaus', Fake), /unknown extension slot "site\.footer\.sttaus"/) +}) + +test('a non-component fill throws', () => { + declareSlot('site.footer.status') + assert.throws(() => registerExtension('uo', 'site.footer.status', { render: true }), /is not a component/) +}) + +test('a second module cannot take a filled slot, and the first keeps it', () => { + // Matches the server's rule exactly (registries.js): first fill wins, second + // is an error. The second half of the assertion is the one that matters — a + // rejected fill must not have half-replaced the incumbent. + declareSlot('admin.users.detail') + registerExtension('uo', 'admin.users.detail', Fake) + assert.throws(() => registerExtension('other', 'admin.users.detail', Other), /already filled by "uo"/) + assert.equal(extensionFor('admin.users.detail'), Fake) +}) + +test('declaring a slot twice throws', () => { + // Core-side programming error: two owners for one position means whichever + // module registered first wins by file order. + declareSlot('site.footer.status') + assert.throws(() => declareSlot('site.footer.status'), /already declared/) +}) + +test('core fills a slot through the same seam a module uses', () => { + // The client twin of registries.registerCore(). Core is a registrant with an + // id like any other, which is what makes slice 3 a deletion: the module + // registers the same slot and core drops its line. + declareSlot('site.footer.status') + registerExtension('core', 'site.footer.status', Fake) + assert.deepEqual(registeredIds(), ['core']) +}) + +test('declareSlot and extensionFor are not on the module-facing registry', () => { + // Declaring is core's alone (§3.7), and reading who filled a slot is core's + // too — the same line featureProviders() draws. registerExtension IS on the + // object, because filling is the whole point. + assert.equal(registry.declareSlot, undefined) + assert.equal(registry.extensionFor, undefined) + assert.equal(typeof registry.registerExtension, 'function') +}) diff --git a/server/src/modules/version.js b/server/src/modules/version.js index f58c441..24b34bc 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,11 +9,18 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.2.0 — the CLIENT registry gained `registerExtension` and core gained client +// extension slots (MODULE_API.md §3.7): the twin of this half's declareSlot / +// registerExtension, for module content inside a core *page* rather than under a +// core route prefix. Nothing on the server changed, and this file bumps anyway — +// the two halves state ONE version, because a module declares a single `coreApi` +// range and is served one chunk (client/src/modules/version.js). +// // 1.1.0 — `ctx` gained `activity.log`, `users.getById` and `site.baseUrl`, each // because module-uo's extraction needed it and none of them could be vendored: // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.1.0' +const MODULE_API_VERSION = '1.2.0' module.exports = { MODULE_API_VERSION } From d667565ae73052b2e90d05177234867ef098f593 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 11 Aug 2026 16:45:22 -0500 Subject: [PATCH 2/2] refactor(modules): move core's UO page content behind the two slots Core declares site.footer.status and admin.users.detail in main.jsx and fills both itself, under owner id `core` -- the client twin of registries.registerCore() and the same trick useShardFlags already uses. The rendered page is unchanged; what changes is that the content now arrives the way a module's will. The footer's Shard Status link becomes ShardStatusLink.jsx, and UserDetail's six UO sections become UserShardSections.jsx. Both are files rather than inline markup so that the client half of phase 3 deletes a registration and a file instead of editing a core page under extraction pressure -- which is also what proves the mechanism before anything depends on it. The user-detail slot is handed userId and not scope. api.admin.userShard is a UO binding that leaves core with the client half, so a slot passing it would hand a module something core is about to delete; an extension builds its own client for the routes it registered at the other end. Core's own fill now does exactly what the module will. Verified in a browser against a real chunk (MODULE_API.md 7.7): a throwaway module fills both slots and renders its own label and target in the footer with core's linkStyle, and receives userId on the admin page; a deliberate render failure is contained to that one spot with the slot named in the console; core's own fills leave the pages byte-identical to before; and with no module installed both slots render nothing. Zero CSP reports throughout. Co-Authored-By: Claude --- client/src/components/ShardStatusLink.jsx | 24 +++ client/src/components/SiteFooter.jsx | 21 ++- client/src/main.jsx | 32 +++- client/src/routes/admin/views/UserDetail.jsx | 147 ++-------------- .../routes/admin/views/UserShardSections.jsx | 163 ++++++++++++++++++ 5 files changed, 247 insertions(+), 140 deletions(-) create mode 100644 client/src/components/ShardStatusLink.jsx create mode 100644 client/src/routes/admin/views/UserShardSections.jsx diff --git a/client/src/components/ShardStatusLink.jsx b/client/src/components/ShardStatusLink.jsx new file mode 100644 index 0000000..ffaea4c --- /dev/null +++ b/client/src/components/ShardStatusLink.jsx @@ -0,0 +1,24 @@ +// ── Core's fill for the `site.footer.status` extension slot ──────────────── +// +// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is +// MODULE_API.md §3.7. +// +// This is the whole of what used to be four lines inline in SiteFooter.jsx, and +// it is a file now for one reason: `/site/shard` is a UO page, so the link goes +// when the client half goes, and core should be deleting a registration rather +// than editing its footer under extraction pressure. +// +// Note what core kept and what it handed over. Core owns the position in the row +// and the separator around it, and passes `linkStyle` so the row stays visually +// one row. The label, the destination, and the decision to render at all are +// this file's — which is exactly the division a module inherits. + +import { Link } from 'react-router-dom' + +export default function ShardStatusLink({ linkStyle }) { + return ( + + Shard Status + + ) +} diff --git a/client/src/components/SiteFooter.jsx b/client/src/components/SiteFooter.jsx index 5b94977..66f65b0 100644 --- a/client/src/components/SiteFooter.jsx +++ b/client/src/components/SiteFooter.jsx @@ -1,5 +1,14 @@ import { Link } from 'react-router-dom' import { useSite } from '../contexts/SiteContext.jsx' +import Slot from '../modules/Slot.jsx' + +const FOOTER_SLOT = 'site.footer.status' + +// Handed to the extension rather than left for it to guess. A module rendering +// its own link in this row should look like the row, and the alternative is +// every module restating core's colours and then drifting from them the next +// time this footer is themed. +const LINK_STYLE = { color: 'var(--accent)', textDecoration: 'none' } export default function SiteFooter() { const { contactEmail, siteTitle } = useSite() @@ -36,10 +45,14 @@ export default function SiteFooter() { {contactEmail} -  ·  - - Shard Status - + {/* A module's spot in the footer, and core supplies only the + position and the styling: the label, the target and whether + anything renders at all are the module's (MODULE_API.md §3.7). + The separator goes through `wrap` rather than sitting beside the + slot, so it shares the extension's fate — no module installed and + a module whose link throws both render nothing here, rather than + the second leaving a stray middot behind. */} + <> · {link}} />  ·  Admin diff --git a/client/src/main.jsx b/client/src/main.jsx index de0fa13..f1c81fa 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.js' -import { registerFeatureProvider } from './modules/registry.js' +import { declareSlot, registerExtension, registerFeatureProvider } from './modules/registry.js' import { useShardFlags } from './lib/useShardFeatures.js' +import ShardStatusLink from './components/ShardStatusLink.jsx' +import UserShardSections from './routes/admin/views/UserShardSections.jsx' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -28,6 +30,34 @@ publishSharedDependencies() // for them by the name they will always have had. registerFeatureProvider('core', 'uo', useShardFlags) +// ── Extension slots (MODULE_API.md §3.7) ─────────────────────────────────── +// +// Declared HERE, in core's own bundle, which is what makes the ordering a fact +// rather than a hope: module chunks are deferred scripts the shell injects after +// this one (§3.1), so a module can never reach registerExtension before the slot +// it names exists. "Unknown slot" therefore always means a typo or a version +// skew, never a load-order accident — which is why that case throws. +// +// Both slots are named for a PLACE, not for a meaning. `site.footer.status` is +// the spot in the footer's info row, not a declaration that core knows what a +// game server's status is; the label, the target and whether anything renders at +// all belong to whoever fills it. A slot typed by its content would put game +// semantics back into core, which is the thing Phase 3 takes out. +declareSlot('site.footer.status') +// Deliberately the same name as the server's slot (MODULE_API.md §2.4): one +// resource, one extension point, two halves. The module with routes under +// /api/v1/admin/users/:id is the module with something to show on that page. +declareSlot('admin.users.detail') + +// And core fills both itself, under owner id `core`, with the components that +// were inline in SiteFooter.jsx and UserDetail.jsx until this slice. The page +// renders exactly what it rendered before, and the mechanism is exercised by +// core's own content from the day it lands rather than first proved by the +// change that depends on it. Slice 3 deletes these three lines and the two files +// they name, and the module registers the same two slots on its way in. +registerExtension('core', 'site.footer.status', ShardStatusLink) +registerExtension('core', 'admin.users.detail', UserShardSections) + // Render on DOMContentLoaded rather than immediately, and that is the one line // of core's boot the module system changes. // diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx index cd552cf..93f9cca 100644 --- a/client/src/routes/admin/views/UserDetail.jsx +++ b/client/src/routes/admin/views/UserDetail.jsx @@ -1,17 +1,16 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { useAsync } from '../../../lib/useAsync.js' -import { dateTime, ago } from '../../../lib/format.js' +import { dateTime } from '../../../lib/format.js' import { api } from '../../../api/client.js' -import CharacterStats from '../../../components/CharacterStats.jsx' -import GameAccounts from '../../../components/GameAccounts.jsx' -import VendorSales from '../../../components/VendorSales.jsx' +import Slot from '../../../modules/Slot.jsx' -// Admin read-only view of one user's shard (uo-link) footprint: linked game -// accounts + character rosters, currently-online characters, houses (incl. -// IDOC) and recent vendor sales — everything scoped to that user's accounts. -// Reached from the Users table's "View" action; Edit stays a separate modal. +// Admin view of one user: who they are, their security posture (trusted devices +// and MFA), and then whatever the installed module contributes about them — +// today core's own UO footprint, via the `admin.users.detail` extension slot +// (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays +// a separate modal. const ROLE_BADGE = { admin: 'badge-admin', @@ -28,114 +27,6 @@ function SectionTitle({ children }) { ) } -// Currently-online characters on the user's accounts, with where they are. The -// per-character Online/Offline badge lives in the roster; this adds location. -function OnlineNow({ scope }) { - const { data } = useAsync(() => scope.online(), [scope]) - if (!data) return null - return ( -
- Online now - {data.length === 0 ? ( -

No characters online right now.

- ) : ( -
    - {data.map((c) => ( -
  • - - - {c.name || '(unnamed)'} - - - {c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'} - -
  • - ))} -
- )} -
- ) -} - -// Shard "standing": city governorships held and guilds led by this user's -// accounts (both reliable current-state lookups). Renders nothing when empty. -function Standing({ scope }) { - const { data } = useAsync(() => scope.standing(), [scope]) - if (!data) return null - const govs = data.governorOf || [] - const guilds = data.guildsLed || [] - if (govs.length === 0 && guilds.length === 0) return null - return ( -
- Standing -
- {govs.map((g) => ( - - Governor of {g.city} - - ))} - {guilds.map((g) => ( - - Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name} - - ))} -
-
- ) -} - -// One house row — the many optional detail fields are gathered here so the -// Houses list stays a simple map. -function HouseRow({ house: h }) { - const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown') - const coords = h.x != null ? ` · ${h.x}, ${h.y}` : '' - const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : '' - const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : '' - return ( -
  • -
    -
    - {h.name || 'Unnamed house'} - {h.isIdoc && IDOC} -
    -
    - {location} - {coords} - {owner} - {shares} -
    -
    -
    - {(h.decay || h.stage) ?
    {h.decay || h.stage}
    : null} - {h.price != null ?
    {Number(h.price).toLocaleString()} gp
    : null} - {h.lastRefreshed ?
    refreshed {ago(h.lastRefreshed)}
    : null} -
    -
  • - ) -} - -// Houses owned by the user's accounts, IDOC first (flagged). -function Houses({ scope }) { - const { data } = useAsync(() => scope.houses(), [scope]) - if (!data) return null - return ( -
    - Houses - {data.length === 0 ? ( -

    No houses recorded for this user’s accounts.

    - ) : ( -
      - {data.map((h) => ( - - ))} -
    - )} -
    - ) -} - // Admin security controls for one user: their trusted devices (view + revoke) and // an MFA reset for a locked-out user. Every action is audit-logged server-side. function SecurityAdmin({ userId }) { @@ -244,25 +135,8 @@ function SecurityAdmin({ userId }) { ) } -function ShardSections({ scope }) { - return ( - <> - - Linked accounts & characters - `/admin/characters/${serial}`} /> - - - - - - ) -} - export default function UserDetail() { const { id } = useParams() - // Memoize so the child components' effects (keyed on `scope`) don't refetch - // on every render. - const scope = useMemo(() => api.admin.userShard(id), [id]) const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id]) if (loading) return @@ -296,7 +170,10 @@ export default function UserDetail() { - + {/* Whatever the installed module has to say about this user, or nothing + at all. Core's own UO sections fill it today (UserShardSections.jsx, + registered in main.jsx) — MODULE_API.md §3.7. */} + ) } diff --git a/client/src/routes/admin/views/UserShardSections.jsx b/client/src/routes/admin/views/UserShardSections.jsx new file mode 100644 index 0000000..aa0890b --- /dev/null +++ b/client/src/routes/admin/views/UserShardSections.jsx @@ -0,0 +1,163 @@ +// ── Core's fill for the `admin.users.detail` extension slot ──────────────── +// +// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below +// is UO, and every one of them leaves core with the client half in slice 3 — +// this file exists so that when they do, core deletes a registration and a file +// instead of unpicking a page. +// +// Core registers it through the same seam a module uses +// (`registerExtension('core', …)` in main.jsx), which is the client twin of the +// server's `registries.registerCore()` and the same trick `useShardFlags` +// already uses for the feature seam. The mechanism is therefore exercised by +// core's own content from the day it lands, rather than first proved by the +// change that depends on it. +// +// The slot hands over `userId` and nothing else — deliberately, not `scope`. +// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot +// that passed it would be handing a module something core is about to delete. +// An extension builds its own client for the routes it registered at the other +// end (MODULE_API.md §3.5), and this file does exactly what the module will. + +import { useMemo } from 'react' +import { useAsync } from '../../../lib/useAsync.js' +import { ago } from '../../../lib/format.js' +import { api } from '../../../api/client.js' +import CharacterStats from '../../../components/CharacterStats.jsx' +import GameAccounts from '../../../components/GameAccounts.jsx' +import VendorSales from '../../../components/VendorSales.jsx' + +// Its own copy, not an export from UserDetail.jsx: six lines of presentational +// furniture that is not in the §3.4 kit, so a module filling this slot would +// vendor the same thing. Core's copy stays behind with core's own security +// panel, which is the other caller. +function SectionTitle({ children }) { + return ( +
    + {children} +
    + ) +} + +// Currently-online characters on the user's accounts, with where they are. The +// per-character Online/Offline badge lives in the roster; this adds location. +function OnlineNow({ scope }) { + const { data } = useAsync(() => scope.online(), [scope]) + if (!data) return null + return ( +
    + Online now + {data.length === 0 ? ( +

    No characters online right now.

    + ) : ( +
      + {data.map((c) => ( +
    • + + + {c.name || '(unnamed)'} + + + {c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'} + +
    • + ))} +
    + )} +
    + ) +} + +// Shard "standing": city governorships held and guilds led by this user's +// accounts (both reliable current-state lookups). Renders nothing when empty. +function Standing({ scope }) { + const { data } = useAsync(() => scope.standing(), [scope]) + if (!data) return null + const govs = data.governorOf || [] + const guilds = data.guildsLed || [] + if (govs.length === 0 && guilds.length === 0) return null + return ( +
    + Standing +
    + {govs.map((g) => ( + + Governor of {g.city} + + ))} + {guilds.map((g) => ( + + Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name} + + ))} +
    +
    + ) +} + +// One house row — the many optional detail fields are gathered here so the +// Houses list stays a simple map. +function HouseRow({ house: h }) { + const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown') + const coords = h.x != null ? ` · ${h.x}, ${h.y}` : '' + const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : '' + const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : '' + return ( +
  • +
    +
    + {h.name || 'Unnamed house'} + {h.isIdoc && IDOC} +
    +
    + {location} + {coords} + {owner} + {shares} +
    +
    +
    + {(h.decay || h.stage) ?
    {h.decay || h.stage}
    : null} + {h.price != null ?
    {Number(h.price).toLocaleString()} gp
    : null} + {h.lastRefreshed ?
    refreshed {ago(h.lastRefreshed)}
    : null} +
    +
  • + ) +} + +// Houses owned by the user's accounts, IDOC first (flagged). +function Houses({ scope }) { + const { data } = useAsync(() => scope.houses(), [scope]) + if (!data) return null + return ( +
    + Houses + {data.length === 0 ? ( +

    No houses recorded for this user’s accounts.

    + ) : ( +
      + {data.map((h) => ( + + ))} +
    + )} +
    + ) +} +export default function UserShardSections({ userId }) { + // Memoized so the child components' effects (keyed on `scope`) don't refetch + // on every render — the same reason UserDetail memoized it before this moved. + const scope = useMemo(() => api.admin.userShard(userId), [userId]) + return ( + <> + + Linked accounts & characters + `/admin/characters/${serial}`} /> + + + + + + ) +}