1 Commits

Author SHA1 Message Date
bf470c7658 spike(modules): carry /public/atlas/* behind the proposed module surface
THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See
modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7.

The six public spawn-atlas routes now live in modules/uo/, reached only through
the ctx/register surface, with the client half loading as a prebuilt ESM chunk.
All three exit criteria met:

  • zero internal-file imports from the module into core; the built chunk has
    zero bare import specifiers and bundles no React
  • routes.manifest.json AND routes.guards.json are byte-identical
  • /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with
    zero CSP violation reports

729 core tests and 81 module tests pass. Verified end to end against the real
database: the schema fragment replays after core's, onBoot runs the atlas
refresh, and the six API URLs answer unchanged.

Two things the spike changed in the contract:

  • ctx.express / ctx.validator. A module lives outside server/, so Node never
    reaches server/node_modules and require('express') fails outright — the
    server-side twin of the one-React rule, which §2.6 had only for the client.
  • window.__rg.jsxRuntime, so a module can build with the automatic JSX
    runtime its tooling already assumes rather than being forced to classic.

And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently
deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while
the route manifest kept all six in the same run. That is exactly the
static-analysis-vs-runtime split the fragment merge exists to prevent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 05:29:35 -05:00
579 changed files with 35382 additions and 114648 deletions

View File

@@ -10,8 +10,5 @@ uploads
server/logs
logs
*.log
# Installed modules are mounted at runtime, never baked into the image. Without
# this a module in the builder's working tree would ship inside every image.
modules
.DS_Store
Thumbs.db

View File

@@ -33,8 +33,8 @@ LOG_FILE=app.log
# BRAND_NAME / BRAND_CONTACT_EMAIL.
BRAND_NAME=Runic Gateway
BRAND_SHORT_NAME=Runic Gateway
BRAND_TAGLINE=an independent game community
BRAND_DESCRIPTION=Runic Gateway — an independent game community. News, screenshots, guides, and community notes.
BRAND_TAGLINE=an independent private Ultima Online shard
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
BRAND_CONTACT_EMAIL=
BRAND_URL=
# Accent color — drives the web theme's --accent and the Discord embed color.
@@ -56,21 +56,6 @@ DB_ROOT_PASSWORD=change-me-root-password
# Auth
JWT_SECRET=change-me-to-a-long-random-string
# Encrypts every secret this site stores at rest (AES-256-GCM): OAuth client
# secrets, the Discord bot token, the mail transport credentials, the uo-link auth
# token. REQUIRED in production — with NODE_ENV=production the app REFUSES TO
# START without it (utils/secretBox.js), so a Compose deployment that leaves it
# blank crash-loops before it ever listens. Development falls back to a key
# derived from JWT_SECRET, with a warning.
#
# Any string; it is hashed to 32 bytes. Generate a long random one and treat it
# like the database password.
#
# Changing it on a live instance does NOT re-encrypt anything: every secret
# already stored becomes unreadable and has to be entered again from the admin
# panel. That is also the reason it is a dedicated key rather than a reuse of
# JWT_SECRET — rotating a session secret must not orphan stored credentials.
SECRET_ENC_KEY=change-me-to-a-different-long-random-string
JWT_EXPIRES_IN=1d
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
@@ -98,14 +83,10 @@ TOTP_CHALLENGE_TTL=5m
ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email is configured in Admin → Settings → Email, not via env: pick a mail
# transport (SMTP) and enter its host, port and credentials, which are stored
# encrypted in the DB. Three postures work — a relay (Mailgun/SES/Postmark) is
# the recommended one, a mailbox provider over SMTP (e.g. smtp.gmail.com:587
# with an app password) is the simplest, and an unauthenticated local MTA on
# port 25 needs no credentials at all. Until one is configured the contact form
# falls back to a mailto: link (recipient = the `contact_email` site setting).
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
# refresh token in the DB. Until it's connected, the contact form falls back to
# a mailto: link (recipient = the `contact_email` site setting).
# CORS — only needed for local dev when the Vite dev server is a different origin.
CLIENT_ORIGIN=http://localhost:5173
@@ -126,32 +107,20 @@ CLIENT_ORIGIN=http://localhost:5173
BOT_INTERNAL_URL=http://bot:4100
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
# ─── Installed modules ───
# A module is a directory on the modules volume (see MODULES_DIR in
# server/.env.example); everything about a specific game lives in one, and core
# knows nothing about any of them. A module may read its own env vars, and they
# belong here because Compose passes this file to the container.
#
# MODULES declares the set this deployment runs, and the container arrives at it
# on its own — no admin panel, no `tar -xf` on the host. One entry per module,
# `<id>@<version>=<install manifest URL>`, whitespace- or comma-separated:
#
# MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
#
# A module already unpacked at the declared version is a no-op that never touches
# the network, so a restart with the internet down brings the site up exactly as
# it was; only a missing or different version is fetched, verified against the
# sha256 its manifest declares, and unpacked. A failure is logged and shown in
# Admin → Modules, and the site starts anyway. The variable owns what is on the
# volume, not what runs — a module disabled from the admin panel stays disabled.
# Leave it unset to install from the admin panel instead.
#
# RunicGateway/Module-uo, for example, reads UOLINK_BASE_URL / UOLINK_WS_URL /
# UOLINK_PROTOCOL as the defaults for its connection to a uo-link sidecar, and
# TOWNCRIER_DURATION_SEC for its news leg. Its README documents them; they are
# left out here rather than half-copied, because a copy of another repo's
# settings is a copy that goes stale silently. With no module installed, none of
# this applies and the site runs as core.
# 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
# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site
# with nothing saved yet — the admin panel's pinned value wins — but set it lower
# if you deliberately run an older sidecar.
UOLINK_PROTOCOL=3
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications

View File

@@ -28,36 +28,3 @@ TOTP_ISSUER=UOMysticmoon
DB_NAME=uomysticmoon
DB_USER=uomm
COOKIE_NAME=uomm_token
# ── The UO module — REQUIRED for this instance, not optional like the vars above.
#
# Core is game-agnostic (docs/website/MODULE_SYSTEM.md): every shard-facing
# surface this instance runs — the shard pages, the player's characters, vendors
# and houses, Admin → Shard, and the uo-link connection itself — lives in
# RunicGateway/Module-uo and reaches the deployment through this line. Without
# it, the same image is a perfectly working site with no game on it.
#
# It is declared here rather than left to Admin → Modules because a compose host
# should arrive at its own set at boot, and because this instance has a shard to
# be down for: the panel path would leave the site game-less between the image
# roll and someone clicking install.
#
# Bump the version deliberately, and read Module-uo's release notes when you do —
# the container resolves this at every start, so changing the version here is
# what upgrades the module. A version already unpacked is a no-op that makes no
# network call at all.
#
# This owns what is ON the volume, never whether the module RUNS: disabling it in
# Admin → Modules keeps it disabled across restarts even though its files return.
MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
# Module-uo reads these as the DEFAULTS for its uo-link connection, used only
# until Admin → Shard has been saved once — after that the encrypted DB config
# (`uo_link_config`) is authoritative and these are ignored. Left unset here on
# purpose: an instance that has already saved Admin → Shard keeps that config
# across the extraction (the module's schema fragment is CREATE TABLE IF NOT
# EXISTS, so the existing row is untouched), and setting them would suggest they
# still decide something. Module-uo's README documents them.
# UOLINK_BASE_URL=
# UOLINK_WS_URL=
# UOLINK_PROTOCOL=

View File

@@ -47,26 +47,10 @@ jobs:
node-version: 20
cache: npm
cache-dependency-path: server/package-lock.json
- name: Check core names no module identifier
# Phase 3's acceptance criterion 1 (MODULE_API.md §5.2): core must not
# name a module's files, import them, route to them, or declare its
# symbols. Before `npm ci`, deliberately — it is plain Node over
# server/ and client/ source with no dependency of its own, so putting it
# first makes a boundary break the first thing a reviewer sees instead of
# something found under a pile of unrelated failures, and it costs
# nothing when it passes.
run: npm run check:modules
- name: Check the engagement subsystem names no external host
# ENGAGEMENT.md §3.2 rule 4 — no transport may ship a default host,
# endpoint or sender. Dependency-free and runs before the install for the
# same reason as the check above: a phone-home is a design break, not a
# test failure, and it should be the first thing a reviewer sees.
run: npm run check:hosts
- name: Install server deps
run: npm ci --prefix server
- name: Run server tests
run: npm test --prefix server
- name: Check the route manifest is current
# The URL surface is frozen while the routers are carved up by capability
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
@@ -75,15 +59,6 @@ jobs:
# of a reviewer instead of letting it pass silently.
run: npm run routes:manifest --prefix server -- --check
- name: Check the engagement trigger manifest is current
# ENGAGEMENT.md 4.3 property 4 - the same mechanism as the route manifest
# above, for the event contract instead of the URL surface. A trigger
# declaration is what a stored template interpolates and what a stored
# rule is written against, so renaming a variable or widening a ceiling
# breaks them silently, at send time, in mail someone already received.
# Regenerating and diffing makes that change something a reviewer reads.
run: npm run engagement:manifest --prefix server -- --check
client-build:
runs-on: ubuntu-latest
steps:
@@ -101,13 +76,9 @@ jobs:
- name: Build client
run: npm run build --prefix client
bot-tests:
# The install still runs first and still catches a broken or out-of-sync
# lockfile before it ships in the bot image — that was this job's whole
# purpose until phase 7 (TEAMS.md §7.1) put real logic in the bot: it now
# pulls slash-command definitions from the app, merges them into the
# whole-set PUT, and runs the defer→dispatch→edit path. None of that is
# reachable from the server suite, and phases 8 and 9 add more of it.
bot-install:
# No tests/build to run; a clean install still catches a broken or
# out-of-sync lockfile before it ships in the bot image.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -118,7 +89,3 @@ jobs:
cache-dependency-path: bot/package-lock.json
- name: Install bot deps
run: npm ci --prefix bot
- name: Run bot tests
# Node's built-in runner, no browser and no Discord connection — the
# interaction is a fake that records what was called on it.
run: npm test --prefix bot

7
.gitignore vendored
View File

@@ -21,13 +21,6 @@ uploads/
server/logs/
logs/
# Installed modules (docs/website/MODULE_SYSTEM.md). Core ships no module, so
# anything here is an operator's install or a developer's scratch copy. The
# directory itself IS tracked, via its README: docker-compose.yml bind-mounts it,
# and a missing bind-mount source is recreated by Docker as root-owned.
modules/*
!modules/README.md
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
# not ours to redistribute. The images live under server/uploads/atlas/, already

View File

@@ -21,12 +21,6 @@ RUN if [ -f client/package.json ]; then \
# Persistent uploads + logs live on mounted volumes.
RUN mkdir -p /app/uploads /app/logs && chown -R node:node /app/uploads /app/logs
# Installed modules are mounted in too (docker-compose.yml), and .dockerignore
# keeps any local modules/ OUT of the image — a module must never be baked in.
# The directory is still created here so a container run without the mount finds
# an empty, writable modules dir rather than no directory at all.
RUN mkdir -p /app/modules && chown node:node /app/modules
USER node
EXPOSE 3000

303
README.md
View File

@@ -8,19 +8,16 @@
[![Security Rating](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=security_rating&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Vulnerabilities](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=vulnerabilities&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
Public site, wiki, and protected admin panel for a game community — a full-stack app
in one repo. Everything specific to a *particular* game lives in an installable
module, not here. Branding is instance-configurable via `BRAND_*` (see
[Branding](#branding)); **UOMysticmoon**, an Ultima Online shard, is the first
instance, and its game half is
[RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo).
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
[Branding](#branding)); **UOMysticmoon** is the first instance.
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 reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). Express serves the built SPA in production.
- **Modules** — the game-specific half of a site is a module dropped onto a volume: it adds routes, database tables, nav entries and whole SPA pages without this repo knowing anything about the game. See [Modules](#modules).
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/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](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives.
@@ -40,7 +37,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
- [Pages & routes](#pages--routes)
- [API endpoints](#api-endpoints)
- [API documentation (Swagger)](#api-documentation-swagger)
- [Modules](#modules)
- [Shard integration (uo-link)](#shard-integration-uo-link)
- [Environment variables](#environment-variables)
- [Security](#security)
- [Logging](#logging)
@@ -51,8 +48,8 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
## Architecture
How the pieces fit together — the React SPA and native app talk to one Express backend
(`router → controller → model → db`), which persists to MariaDB. Anything that knows
what game this site is about lives in an installed module, on the right of the diagram.
(`router → controller → model → db`), which persists to MariaDB and bridges to the live
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
```mermaid
flowchart TB
@@ -72,29 +69,32 @@ flowchart TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin · player"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
loader["modules/loader.js<br/>scans the volume · mounts · registries · lifecycle"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>installed_modules · &lt;module&gt;_*")]
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Module side ----------
subgraph modside["modules/&lt;id&gt;/ &nbsp;— installed, not built (e.g. Module-uo)"]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
modsrv["server/ — routers, models, schema fragment<br/>reaches core only through ctx"]
modcli["client/dist/entry.js — prebuilt ESM chunk<br/>React shared via window.__rg"]
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
game["The game<br/>whatever the module talks to<br/>(for Module-uo: a ServUO shard,<br/>via the uo-link sidecar)"]
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
@@ -104,42 +104,40 @@ flowchart TB
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
loader -->|"mounts under /api/v1/&lt;tier&gt;/&lt;prefix&gt;"| router
loader -->|"require() + register(ctx, api)"| modsrv
modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
modsrv <--> game
browser -->|"&lt;script type=module&gt; injected by htmlShell"| modcli
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef mod fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord,game ext;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class modsrv,modcli mod;
class sidecar,servuo bridge;
```
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
All three surfaces produce the *same* session via the session layer.
- **Core knows nothing about any game.** Routes, tables, nav entries, SPA pages and push streams for
a specific game arrive from a module the operator installed. Core provides the seams; the module
fills them. See [Modules](#modules).
- **A module that fails must never take the site down.** The loader catches failures across a
module's whole lifecycle and marks that one module `startup_failed`; the site comes up with its
routes and nav absent, and the admin panel says why.
- **Sensitive events stay private.** Events fan out to browsers over two SSE channels — a public
allowlist stream and an admin-only stream that adds staff audit / cheat / login events. Which
event kinds are public is decided by the module that publishes them, and core enforces the split.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
---
@@ -151,7 +149,7 @@ flowchart TB
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
| Email | Nodemailer over a configurable mail transport — SMTP (relay, mailbox provider or your own MTA), set up in the admin panel — with a `mailto:` fallback |
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
@@ -166,13 +164,12 @@ website/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
│ │ ├─ app.js middleware + static SPA + routes
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin / player route groups
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities · modules (.model + .db)
│ │ ├─ modules/ loader (scan · validate · mount) · registries (the seams) · lifecycle (boot/shutdown + reconcile)
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger · htmlShell
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
│ ├─ db/ schema.sql + seed.js
│ ├─ swagger/ swagger.js (generator config) · swagger-output.json (generated, core only) · docsSpec.js (merges module fragments at request time)
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
│ └─ .env.example
├─ client/ React + Vite SPA
│ ├─ src/
@@ -181,11 +178,9 @@ website/
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
│ │ ├─ contexts/ AuthContext, SiteContext
│ │ ├─ modules/ the client registry: routes · nav · slots · feature gates · window.__rg
│ │ ├─ api/client.js fetch wrapper (sends cookies)
│ │ └─ styles/theme.css design tokens
│ └─ public/assets/img/ hero image
├─ modules/ installed modules, one directory each — a Docker bind mount; empty here
├─ Dockerfile builds client → serves via Express
├─ docker-compose.yml app + MariaDB
├─ .env.example root env (used by Compose)
@@ -216,12 +211,7 @@ cp .env.example .env
# Edit .env and set at least:
# DB_PASSWORD, DB_ROOT_PASSWORD (any strong values)
# JWT_SECRET (a long random string)
# SECRET_ENC_KEY (a different long random string)
# BOT_INTERNAL_KEY (a third one, 16+ chars — even with no bot)
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
#
# SECRET_ENC_KEY and BOT_INTERNAL_KEY are not optional in production: the app
# refuses to start without them, so the container crash-loops before it listens.
docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
# pin a specific build (reproducible deploy / rollback):
@@ -232,10 +222,6 @@ IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
- Health check: `GET http://localhost:3000/api/health``{ "status": "ok" }`
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
- Modules: installed into `./modules` on the host (bind-mounted to `/app/modules`), never baked into
the image — an operator adds one to a pull-only deployment without building anything. Adding or
removing one takes a `docker compose restart app`; the scan is synchronous at startup. See
[`modules/README.md`](modules/README.md).
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
the dev file, which adds `build:` back:
@@ -316,7 +302,7 @@ npm start # node server → serves API + SPA at http://localhost:3
| `/site/screenshots` | Screenshot gallery |
| `/site/five-on-friday` | Five on Friday |
| `/site/newsletter` · `/site/newsletter/:id` | Newsletter list + issue |
| `/site/about` · `/site/status` | About · Site status |
| `/site/about` · `/site/status` | About · Shard status |
| `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) |
**Admin** (cookie auth, `noindex`):
@@ -334,13 +320,6 @@ npm start # node server → serves API + SPA at http://localhost:3
| `/admin/users` | User management |
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
**Player** (any signed-in account, `noindex`): `/player` and its self-service views. Staff are a
superset of players and reach these too.
An installed module adds its own pages under `/<id>/*`, `/admin/<id>/*` and `/player/<id>/*` — for
Module-uo that is `/uo/shard`, `/admin/uo/link`, `/player/uo/characters` and the rest. Core does not
know their names; they arrive with the module and are interleaved into the nav.
---
## API endpoints
@@ -352,15 +331,9 @@ know their names; they arrive with the module and are interleaved into the nav.
| 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) |
| Player | `/api/v1/player` (`me`, credentials, 2FA, identities, appeals) | cookie/bearer (any signed-in account) |
| Modules | `/api/v1/public/modules` — id, name, version and capabilities of the modules currently serving | none |
**Module routes are not in this table**, because they are not core's. An installed module mounts
under `/api/v1/public/<prefix>`, `/api/v1/admin/<prefix>` and `/api/v1/player/<prefix>`; which
prefixes exist depends on what is installed. Module-uo, for instance, serves 72 routes under
`/shard`, `/atlas` and `/uo-link` — see its own
[`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json).
On a running instance, `/api/docs` lists everything, core and modules together.
| 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`.
@@ -403,28 +376,6 @@ npm run swagger # → server/swagger/swagger-output.json
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
not crash).
**The committed spec is core only, and the served one is not.** swagger-autogen is *static
analysis* — it parses `src/app.js` as text and follows the literal `app.use(…)` chain — so it can
see neither an installed module (which arrives on a volume long after the image was built, and
mounts through a call no parser can follow) nor an extension slot (whose router is created empty and
filled later). Both are handled by merging a **fragment**:
- **Extension slots** contribute at generation time, from `server/swagger/slotSpecs.js`, so they are
in the committed file.
- **Modules** contribute at request time, from the `swagger-fragment.json` each one ships, merged by
`server/swagger/docsSpec.js`. So `/api/docs.json` on a running instance describes more than
`npm run swagger` produces here, and `swagger-output.json` stays reproducible on any machine
regardless of what is installed.
**Core wins every key collision** — a module cannot redefine a core path, tag or schema by shipping
one with the same name; the collision is logged and the module's version dropped.
One thing worth knowing if you edit an annotation: swagger-autogen **reports a broken one and then
succeeds anyway**, dropping it. `npm run swagger` now captures those diagnostics and fails, which is
how two annotations that had been silently documenting an empty request body were found. If it
rejects yours, the usual causes are an object literal a brace short, or a `"` or backtick inside a
single-quoted description (it re-quotes both to `'` before evaluating).
### The route manifest (frozen URL surface)
`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
@@ -440,10 +391,9 @@ npm run routes:manifest -- --check # exit 1 if either file is stale (what CI ru
The generator walks the live Express stack (runtime introspection, not source parsing — a route's path
sits on the line *after* `router.get(`, which defeats greps) and keeps only
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads`, `/brand`
and installed modules' `/modules/<id>` chunks are filesystem-conditional static mounts, not API
contract, so they are excluded and the output depends neither on whether the client has been built
nor on which modules are mounted.
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` and `/brand`
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does
not depend on whether the client has been built.
Two generated files, two very different meanings:
@@ -457,101 +407,94 @@ annotated routes appear), the manifest records reality.
---
## Modules
## Shard integration (uo-link)
**Everything specific to a game is a module.** Core has no idea what an "account", a "character" or
a "shard" is; it provides seams, and a module fills them. That is what makes one image able to run a
site for any game rather than for Ultima Online in particular.
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:
**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/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.
The design of record is
[MODULE_SYSTEM.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md);
the normative contract — the one to read before writing a module — is
[MODULE_API.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md).
The worked example is [RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo),
which is where everything this README used to describe under *Shard integration (uo-link)* now
lives: the sidecar client, the ingest dispatcher, account linking, the town crier, the spawn atlas,
and every page that renders them.
### Setting up the shard side
### An operator never builds anything
That constraint shapes the whole design. Installing a module is the WordPress-plugin experience — an
admin-panel action, or a directory dropped onto the `modules/` volume — because production runs a
prebuilt, pull-only image with no toolchain in it. So a module ships **assembled**: its client half
is a prebuilt ESM chunk that resolves React from a `window.__rg` global core owns (an import map
would have to be inline, and the CSP is `script-src 'self'`), and its one runtime dependency travels
inside the tarball.
You do not build or place any of it by hand. The
**[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer)** runs on the
shard host, deploys the ServUO plugin and the uo-link sidecar as a matched, protocol-checked pair,
registers the sidecar as a service, and ends by printing the four values this site needs:
```
modules/
└─ uo/ one directory per module; the id is the directory name
├─ module.json id, version, coreApi range, mounts, extensions, capabilities
├─ swagger-fragment.json merged into /api/docs.json while the module is running
├─ server/ routers, models, and an idempotent schema.sql fragment
└─ client/dist/entry.js the prebuilt chunk, injected by utils/htmlShell.js
Base URL http://<shard-host>:8080
WebSocket URL ws://<shard-host>:8080/ws
Protocol version 3
Auth token 4f9c…
```
`modules/` is a bind mount in `docker-compose.yml`, so placing a directory there by hand is a
supported install. The directory is tracked in git (via its README) on purpose: Docker recreates a
*missing* bind-mount source as `root:root`, and the container is uid 1000.
Paste them into **Admin → Shard** here and the bridge is live. The operator guide is
[installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md);
its [Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
is the same deployment done by hand, still supported, for a host that cannot run the binary or a
developer working from a source tree.
### Three ways in, and none of them is a build
Nothing here needs the shard to exist: with no sidecar configured the site renders normally and
shows the shard offline.
| | How | Where it fits |
|---|---|---|
| **Admin panel** | Admin → Modules, paste the URL of a release's install manifest | The click path. Installs, upgrades, disables, uninstalls and purges, with a restart button — no shell on the box |
| **`MODULES`** | Declare the set in the environment; the container resolves it at every start | The compose-managed host. The running set is a line in a file you version-control, not the residue of past clicks |
| **By hand** | `tar -xf module-uo-0.3.0.tar.gz -C ./modules && mv modules/module-uo-0.3.0 modules/uo`, then restart | Development, and any host where the other two do not fit |
`MODULES` takes one entry per module, whitespace- or comma-separated:
### How it works
```
MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
```
The id and the version are written out rather than discovered inside the manifest so that **the
no-op case needs no network**: a module already unpacked at the declared version is answered by
reading its own `module.json`, so a restart with the internet down brings the site up exactly as it
was. Only a missing or different version is fetched, and it goes through the same
verify-and-unpack path — allowlisted `https` host, sha256 from the manifest, whole-archive
inspection before anything is written — that the admin panel uses. A version that cannot be
resolved is logged and shown on the admin screen; **it never stops the site from starting**.
- **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 <token>` 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.
The declaration owns what is *on the volume*, never what runs. A module disabled from the admin
panel gets its files back at the next start and stays disabled, because the row and the variable are
answering different questions.
### Account linking
### What a module gets, and what it may not do
A player (or staff member) proves ownership of a game account without sharing any game credentials:
At boot, `app.js` scans the volume synchronously, validates each `module.json`, and calls the
module's `register(ctx, api)`:
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`.
- **`ctx` is everything core hands over** — the database, the logger, settings, the session reader,
push, the secret box, the middleware, the rate-limit factory, the activity log, and **express
itself**. A module lives outside `server/`, so Node's resolver never reaches core's
`node_modules`; anything it must share has to be handed to it, or there would be two Expresses and
two Reacts in one process.
- **`api` is everything it may register** — routes (one prefix per tier), an extension slot fill,
notification streams, a news-announce leg, a post hook, and `onBoot`/`onShutdown`.
- **It may not reach into core's tree**, mount outside its declared prefixes, or create tables
outside its `<id>_` prefix. Each of those is checked, in the module's CI and again by the loader.
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.
Two things are guaranteed regardless of what a module does. **A failure never takes the site down**:
the loader catches everything from `require` to `onBoot`, marks that module `startup_failed`, and
the site comes up with its routes and nav absent and the reason on the admin screen. And **no URL of
core's may move** — a module that displaced one is caught by the frozen route manifest, which is
generated from a real core with the module loaded.
### What each audience sees
### What is running right now
| 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). |
```
GET /api/v1/public/modules
{ "modules": [ { "id": "uo", "name": "Ultima Online", "version": "0.3.0",
"capabilities": ["shard", "atlas", "market", …] } ] }
```
Anonymous, database-free, never site-mode gated, and **`started` modules only** — a module that is
disabled or failed is absent, exactly as its routes and its nav already are. Clients feature-detect
against it; they do not use it to decide what to load (the HTML shell injects each chunk's tag).
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.
---
@@ -564,9 +507,6 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `NODE_ENV` | `production` | |
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
| `MODULES_DIR` | `<repo>/modules` | where installed modules are scanned from (`/app/modules`, bind-mounted, in Compose) |
| `MODULES` | — | the module set this deployment runs, resolved at every start: `<id>@<version>=<install manifest URL>`, whitespace/comma separated. Already at the declared version = no network. A failure is logged and shown in Admin → Modules, never fatal. See [Modules](#modules) |
| `MODULE_SOURCE_HOSTS` | `gitea.whitlocktech.com` | **bootstrap only** — seeds the `module_source_hosts` setting on first boot; after that the setting is authoritative and is edited in Admin → Modules |
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
@@ -584,18 +524,19 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
| _Email_ | — | configured in Admin → Settings → Email (transport + credentials), never via env; recipient = `contact_email` setting. Upgrading from the removed Gmail connect flow: see [`docs/website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md) |
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for legs that are due or retrying. Which legs exist is up to what has registered one — Discord is core's; a module may add its own |
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) |
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
---
## Branding
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
image can run as any community. With none set, everything renders as **Runic Gateway**.
image can run as any shard. With none set, everything renders as **Runic Gateway**.
| Var | What |
|---|---|
@@ -677,11 +618,9 @@ run this repo as UOMysticmoon.
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through a mail
transport configured in the admin, whose credentials are stored AES-GCM-encrypted and are
write-only over the API (never returned, never in env); no transport ships a default host or
sender, so an unconfigured deployment sends nowhere. The contact form falls back to a `mailto:`
link when unconfigured.
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
contact form falls back to a `mailto:` link when unconfigured.
---

View File

@@ -6,7 +6,6 @@
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "node --test test/*.test.js",
"dev": "nodemon src/server.js"
},
"keywords": ["discord", "discord.js"],

View File

@@ -5,7 +5,6 @@ const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
const createLogger = require('../utils/logger')
const commands = require('./commands')
const dynamicCommands = require('./dynamicCommands')
const messageFilter = require('./messageFilter')
const scheduler = require('../scheduler/scheduler')
const roleMenuHandler = require('./roleMenuHandler')
@@ -23,46 +22,12 @@ let status = 'disconnected' // disconnected | connecting | connected | error
let statusDetail = null
let lastConnectedAt = null
// One whole-set PUT of the bot's own commands plus whatever the app has
// registered (TEAMS.md §7.1). Because it replaces the set rather than adding to
// it, DEREGISTRATION is free: a module that is gone is simply absent from the
// next pull, and nobody has to remember to take its command back.
async function registerCommands(applicationId, targetGuildId) {
const dynamic = dynamicCommands.definitions()
const rest = new REST({ version: '10' }).setToken(client.token)
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
body: [...commands.all.map((c) => c.data), ...dynamic],
body: commands.all.map((c) => c.data),
})
log.info('registered guild slash commands', {
guildId: targetGuildId,
builtIn: commands.all.length,
fromApp: dynamic.length,
})
}
/**
* Re-pull the app's commands and re-register the set if it moved.
*
* Called on `ready` and again whenever the app nudges
* (`POST /internal/refresh-commands`). A no-op when nothing changed, so a nudge
* per module state change costs one cheap GET rather than a REST.put per
* install — and a disconnected bot does nothing at all, since there is no
* application to register against until it logs in.
*/
async function refreshCommands() {
const result = await dynamicCommands.pull()
if (!result.ok || !result.changed) return result
if (!client || !client.isReady()) return result
try {
await registerCommands(client.application.id, guildId)
} catch (err) {
// The PUT is all-or-nothing: a definition Discord rejects costs every
// command, the built-ins included. Loud, and never fatal to the process.
log.error('re-registering slash commands failed — the previous set is still live', {
message: err.message,
})
}
return result
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
}
async function stop() {
@@ -89,11 +54,6 @@ async function stop() {
// failure here leaves the client connected but flags an error status.
async function onReady() {
try {
// Pull BEFORE the single PUT, so the app's commands are in the very first
// registration rather than appearing a beat later. The pull never throws —
// an unreachable app costs the module commands and nothing else, and the
// bot's own set registers exactly as it always did.
await dynamicCommands.pull()
await registerCommands(client.application.id, guildId)
await scheduler.start(client)
tempRoleSweeper.start(client)
@@ -110,19 +70,14 @@ async function onReady() {
}
}
// Route an interaction: role-menu handler first, then chat-input slash commands
// — the bot's own, then the app's. Built-ins are consulted FIRST and the pull
// already drops any module name that collides with one, so the two orderings
// agree; checking here as well means a name that somehow reached Discord twice
// still runs the bot's version rather than whichever registry answered first.
// Route an interaction: role-menu handler first, then chat-input slash commands.
async function onInteractionCreate(interaction) {
if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
if (!command && !dynamicCommands.has(interaction.commandName)) return
if (!command) return
try {
if (command) await command.execute(interaction)
else await dynamicCommands.execute(interaction)
await command.execute(interaction)
} catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
@@ -191,4 +146,4 @@ function getConnection() {
return { client, guildId }
}
module.exports = { start, stop, getStatus, getConnection, refreshCommands }
module.exports = { start, stop, getStatus, getConnection }

View File

@@ -1,242 +0,0 @@
// Slash commands whose DEFINITION and HANDLER live in the website process
// (TEAMS.md §7.1). The bot pulls the definitions, registers them alongside its
// own, and executes one by deferring, asking the app, and editing the reply in.
//
// Everything Discord-specific is here and nothing else is: the app's dispatcher
// resolves the actor, enforces access and produces a platform-neutral envelope,
// and this file turns that envelope into an interaction reply. A module never
// touches an interaction, which is what makes the registration API something a
// second platform could implement.
const { PermissionFlagsBits } = require('discord.js')
const appInternal = require('../site/appInternalClient')
const staticCommands = require('./commands')
const createLogger = require('../utils/logger')
const log = createLogger('dynamic-commands')
// §7.1.1's four types, and the only four. The app rejects anything else at
// registration; this map is the second half of that agreement.
const OPTION_TYPE = { string: 3, integer: 4, boolean: 5, user: 6 }
// The pulled set, and the app's module-state counter it came from. `null`
// version means "never successfully pulled", which is distinct from 0 ("pulled
// while the app had no modules loaded") — the first should retry, the second is
// a true answer.
let pulled = []
let version = null
/**
* Ask the app for the current definitions.
*
* **A failed pull KEEPS the previous set.** The app being briefly unreachable is
* not the same as it having no commands, and treating it as such would
* deregister every module command from Discord on a restart blip — then
* re-register them a minute later, with members watching commands appear and
* disappear. Nothing changes until the app actually answers.
*
* @returns {Promise<{ok: boolean, changed: boolean, count: number}>}
*/
async function pull() {
const res = await appInternal.fetchCommands()
if (!res.ok) {
log.warn('command pull failed — keeping the set already registered', {
error: res.error,
holding: pulled.length,
})
return { ok: false, changed: false, count: pulled.length }
}
const { version: pulledVersion, commands } = res.data || {}
const next = Array.isArray(commands) ? commands.filter(usable) : []
const changed = version === null || pulledVersion !== version || next.length !== pulled.length
pulled = next
version = typeof pulledVersion === 'number' ? pulledVersion : 0
return { ok: true, changed, count: pulled.length }
}
/**
* Drop a pulled definition the bot cannot honour.
*
* **The name collision the app cannot see.** The app validates a command against
* everything IT has registered; it does not know the bot's own static array
* exists. A module registering `ping` would produce two `ping` entries in one
* `REST.put`, which Discord rejects as a batch — taking down every command
* including the bot's own. The bot's built-ins win, because they are the ones a
* module cannot be asked to change.
*/
function usable(definition) {
if (!definition || typeof definition.name !== 'string') return false
if (staticCommands.get(definition.name)) {
log.warn('module slash command collides with a built-in and is ignored', {
command: definition.name,
owner: definition.owner,
})
return false
}
return true
}
/**
* The pulled definitions as Discord command data, for the whole-set PUT.
*
* `access: 'staff'` becomes a Discord-side permission default; `linked` cannot
* be expressed in Discord's permission model at all — there is no "has a website
* account" predicate — so it is simply not advertised and the app's dispatcher
* refuses it. That asymmetry is the reason §7.1 says access is enforced twice
* and that only the server half is the gate.
*/
function definitions() {
return pulled.map((c) => {
const data = {
name: c.name,
description: c.description,
options: (c.options || []).map((o) => ({
name: o.name,
description: o.description,
type: OPTION_TYPE[o.type],
required: Boolean(o.required),
...(o.choices ? { choices: o.choices } : {}),
})),
}
if (c.access === 'staff') data.default_member_permissions = PermissionFlagsBits.ModerateMembers.toString()
return data
})
}
/** Is this a command the app owns? Asked before the static registry is consulted. */
const has = (name) => pulled.some((c) => c.name === name)
// Read the options the member actually supplied, by the names the definition
// declared. A `user` option is passed on as the Discord user id and nothing else
// — a handler receives platform ids, never a platform object.
function collectOptions(interaction, definition) {
const out = {}
for (const option of definition.options || []) {
const supplied = interaction.options.get(option.name)
if (supplied === null || supplied === undefined) continue
out[option.name] = option.type === 'user' ? String(supplied.value) : supplied.value
}
return out
}
// What the caller sees when the app declined. The COPY lives here rather than in
// the app on purpose: the app answers with a machine reason, and how a refusal is
// phrased to a member is the platform's own voice.
function refusal({ reason, access }) {
if (reason === 'forbidden' && access === 'linked') {
return 'Link your Discord account on the site to use this command.'
}
if (reason === 'forbidden') return 'You do not have access to that command.'
if (reason === 'unknown') return 'That command is no longer available.'
return 'Something went wrong running that command.'
}
// Envelope → interaction payload. A response with fields or a title is an embed;
// a bare `text` is plain content, which reads better for a one-line answer.
function render(envelope) {
const { text, title, fields, url } = envelope
if (!title && !fields) return { content: text || '' }
const embed = {}
if (title) embed.title = title
if (text) embed.description = text
if (url) embed.url = url
if (fields) embed.fields = fields
return { embeds: [embed] }
}
/**
* Deliver the envelope at the privacy the HANDLER asked for, not the privacy the
* deferral guessed.
*
* When the two agree — the ordinary case — this is one `editReply`. When the
* handler wants a private answer to a publicly deferred command, the deferred
* reply is deleted and the answer arrives as an ephemeral follow-up: the
* interaction token stays valid, so this is a supported path rather than a
* trick, and the cost is a "thinking…" that appears and vanishes.
*
* There is no reverse case. A command deferred ephemerally is one whose answers
* are all about the caller's own account, and nothing it returns should become
* public because a handler forgot a flag.
*/
async function reply(interaction, envelope, deferredEphemeral) {
const payload = render(envelope)
if (!envelope.ephemeral || deferredEphemeral) {
await interaction.editReply(payload)
return
}
await interaction.deleteReply()
await interaction.followUp({ ...payload, ephemeral: true })
}
/**
* Defer, dispatch, edit.
*
* **The deferral comes first, always.** Discord gives three seconds to acknowledge
* an interaction; the app is given four to answer. Deferring before the dispatch
* is what keeps the website out of that critical path entirely — a wedged handler
* costs its own reply and never an "application did not respond".
*
* A failure at any point after the defer is an edit, not a reply: the interaction
* has already been acknowledged, and `reply()` on a deferred interaction throws.
*/
async function execute(interaction) {
const definition = pulled.find((c) => c.name === interaction.commandName)
if (!definition) return false
// **Ephemerality is fixed at the DEFERRAL, which happens before the answer
// exists.** That is Discord's rule, not a choice here, and it is the whole
// reason this needs care: the handler decides privacy per answer — a refusal
// is private, a guild summary is not — and by the time it says so the reply is
// already public or already not.
//
// So: defer for the common case (public, or private for a command that only
// ever speaks about the caller's own account), and if the envelope disagrees,
// reconcile below. Getting this wrong is not cosmetic — the live walk caught it
// posting "guild information is not shown to your account" into the channel,
// which announces a member's access level to everyone in it.
const ephemeral = definition.access === 'linked'
await interaction.deferReply({ ephemeral })
const res = await appInternal.dispatchCommand({
command: definition.name,
options: collectOptions(interaction, definition),
platformUserId: interaction.user.id,
guildId: interaction.guildId,
})
// A transport failure and a handler failure are the same sentence to the
// member and different lines in the log: one is the app being unreachable,
// the other is a module's code.
// A refusal is ALWAYS private, whatever the command's usual privacy: "you do
// not have access to that" is about one member and belongs to one member.
if (!res.ok) {
log.warn('command dispatch failed', { command: definition.name, error: res.error })
await reply(interaction, { text: refusal({ reason: 'error' }), ephemeral: true }, ephemeral)
return true
}
if (!res.data || !res.data.ok) {
await reply(interaction, { text: refusal(res.data || {}), ephemeral: true }, ephemeral)
return true
}
const envelope = res.data.response || {}
await reply(interaction, envelope, ephemeral)
// The private aside beside a public answer (§9 answer 5). Skipped when the
// reply was already private — the member would just be told the same thing
// twice, in the same place.
if (envelope.notice && !ephemeral && !envelope.ephemeral) {
await interaction.followUp({ content: envelope.notice, ephemeral: true })
}
return true
}
// Test-only: the pulled set is process-global, so a test that pulls has to be
// able to hand the process back.
function _reset() {
pulled = []
version = null
}
module.exports = { pull, definitions, has, execute, _reset }

View File

@@ -1,80 +0,0 @@
// Team notifications posted into an operator-configured channel (TEAMS.md §7.2).
//
// **The channel comes from the app, not from guild_config.** `newsAnnounce` looks
// its channel up here because there is exactly one #news; a Team's destination is
// per-Team configuration living in `team_integration_config`, and a bot that
// resolved it would need a second copy of that table and a second place for it to
// drift. The app sends the id it already decided on.
//
// **Everything this file knows about a Team it was told.** No lookups, no
// membership checks, no access decisions: whether this content may reach this
// channel was settled on the site, where the acknowledgement that gates it lives.
// The bot is the transport, exactly as it is for slash commands.
const { EmbedBuilder } = require('discord.js')
const brand = require('../brand')
const createLogger = require('../utils/logger')
const log = createLogger('team-notify')
// Discord's own limits. Truncating here rather than trusting the app is not
// distrust — an embed that exceeds them is rejected wholesale, and a message
// silently not appearing is the worst failure mode this path has.
const TITLE_MAX = 256
const DESCRIPTION_MAX = 4096
const clamp = (value, max) => {
const text = String(value || '').trim()
if (!text) return null
return text.length > max ? `${text.slice(0, max - 1)}` : text
}
// What each stream is called in a channel. The app composes the BODY; this is
// only the label above it, and it is here because it is Discord presentation —
// the same reason the embed colour is.
const HEADINGS = {
'team.member.joined': 'New member',
'team.leadership.changed': 'Leadership change',
'team.forum.post': 'New forum post',
'team.announcement': 'Announcement',
}
async function postTeamNotification(client, { channelId, stream, teamName, teamUrl, title, body, url }) {
if (!channelId) throw new Error('No channel id supplied.')
const channel = await client.channels.fetch(channelId).catch(() => null)
if (!channel || !channel.isTextBased()) {
throw new Error('Configured channel is missing, not text-based, or not visible to the bot.')
}
const heading = HEADINGS[stream] || 'Team update'
const name = clamp(teamName, 120) || 'A team'
const embed = new EmbedBuilder()
.setColor(brand.accentInt)
// The Team is the AUTHOR line and the event is the title, not the other way
// round: a channel carrying one Team's events would otherwise repeat its name
// as every heading, and a channel carrying several needs the name to be the
// thing the eye lands on first.
.setAuthor(teamUrl ? { name, url: teamUrl } : { name })
.setTitle(clamp(title, TITLE_MAX) || heading)
if (url) embed.setURL(url)
// Both a title and a body means a forum post: the heading has to go somewhere
// or "New forum post" and "Announcement" become indistinguishable once the
// thread title takes the title slot.
//
// **Clamped AFTER the heading is prepended, not before.** Clamping the body and
// then adding a prefix produces a description one heading longer than the limit,
// which discord.js rejects outright — so an over-long post would not arrive at
// all rather than arriving truncated. The prefix is part of what has to fit.
const composed = title && body ? `**${heading}**\n${String(body)}` : body
const description = clamp(composed, DESCRIPTION_MAX)
if (description) embed.setDescription(description)
await channel.send({ embeds: [embed] })
log.info('team notification posted', { stream, channelId, team: name })
}
module.exports = { postTeamNotification, HEADINGS, clamp, TITLE_MAX, DESCRIPTION_MAX }

View File

@@ -1,315 +0,0 @@
// Per-Team voice channels (TEAMS.md §7.3, phase 9).
//
// **The site decides; this file compares and applies.** Every judgement — which
// Teams qualify, who may enter, what the channel is called — was made on the site
// and arrives in the request. What cannot be made there is the DIFF: which of
// those people already hold the role, whether the channel still exists, whether
// the category was deleted last week. That is live guild state, only this process
// can see it, and shipping it to the site to be compared and shipped back would
// be a copy of the guild in a database that cannot watch it change.
//
// So the contract is "make it look like this", not "do these calls".
//
// **Access is a per-Team ROLE.** §7.3 designed per-member permission overwrites
// with a role only above ~90 members; the org lead settled on roles always
// (2026-08-18). The channel therefore carries exactly three kinds of overwrite —
// @everyone denied, the Team's role allowed, and each operator-designated staff
// role allowed — and membership is the role's member list rather than a hundred
// entries on the channel.
const { ChannelType, PermissionFlagsBits } = require('discord.js')
const createLogger = require('../utils/logger')
const log = createLogger('team-voice')
// The category every Team channel is created under. Created on the first pass
// that needs one; the site stores the id and sends it back next time.
const CATEGORY_NAME = 'Teams'
// discord.js REST error codes for "the thing you are addressing is already gone".
// A teardown that finds its target missing has SUCCEEDED — the desired end state
// holds — and the same is true of a sync that finds a channel a human deleted,
// which simply becomes a create.
const UNKNOWN_CHANNEL = 10003
const UNKNOWN_ROLE = 10011
const isMissing = (err) => err && (err.code === UNKNOWN_CHANNEL || err.code === UNKNOWN_ROLE)
// What a Team member may do in their channel, and what @everyone may not. Both
// halves are needed: denying ViewChannel alone still leaves Connect resolvable
// for anyone who has the id, and allowing ViewChannel alone shows a channel
// nobody can enter.
const ACCESS_BITS = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect]
/**
* Can this bot do §7.3's job in this guild?
*
* Asked before an operator may switch voice on, and again at the top of every
* pass. The site has no way to know: the operator invites the bot by hand, there
* is no invite URL with a permission integer anywhere in this project, and an
* unticked box means every call fails with nothing to point at.
*
* `bot_role_position` is reported because it is the second, quieter failure:
* ManageRoles lets the bot create a role, but it can only GRANT roles below its
* own highest one. A bot sitting at the bottom of the role list creates roles it
* then cannot hand to anybody — which looks exactly like a channel nobody can
* enter, with no error anywhere.
*/
async function preflight(client, guildId) {
const guild = await client.guilds.fetch(guildId)
const me = guild.members.me || (await guild.members.fetchMe())
return {
connected: true,
guild_id: guild.id,
can_manage_channels: me.permissions.has(PermissionFlagsBits.ManageChannels),
can_manage_roles: me.permissions.has(PermissionFlagsBits.ManageRoles),
// The guild's whole role list, not just the ones this feature made. The
// 250-role cap is guild-wide and shared with everything the operator created
// themselves, so counting ours would promise headroom that is not there.
role_count: guild.roles.cache.size,
bot_role_position: me.roles.highest.position,
}
}
/** The `Teams` category, reusing the one we were given when it is still there. */
async function ensureCategory(guild, categoryId) {
if (categoryId) {
const existing = await guild.channels.fetch(categoryId).catch(() => null)
if (existing && existing.type === ChannelType.GuildCategory) return existing
log.warn('the configured Teams category is gone; making another', { categoryId })
}
const created = await guild.channels.create({
name: CATEGORY_NAME,
type: ChannelType.GuildCategory,
reason: 'Team voice channels',
})
log.info('created the Teams category', { categoryId: created.id })
return created
}
/**
* The Team's own role.
*
* A rename is applied but never allowed to fail the pass: a Team's name is the
* least important thing here and Discord rate-limits name edits hard, so losing
* one is worth strictly less than losing the access change in the same request.
*/
async function ensureRole(guild, roleId, name) {
let role = roleId ? await guild.roles.fetch(roleId).catch(() => null) : null
let created = false
if (!role) {
role = await guild.roles.create({
name,
// Not mentionable and not hoisted: this role exists to open a door, and a
// Team with two hundred members should not become a way to ping them all or
// a second copy of the member list down the sidebar.
mentionable: false,
hoist: false,
reason: 'Team voice access',
})
created = true
log.info('created a team role', { roleId: role.id, name })
} else if (role.name !== name) {
await role.setName(name, 'Team renamed').catch((err) => {
log.warn('could not rename the team role', { roleId: role.id, message: err.message })
})
}
return { role, created }
}
/** The overwrites a Team channel carries, in the order Discord takes them. */
function overwritesFor(guild, role, staffRoleIds) {
const overwrites = [
{ id: guild.roles.everyone.id, deny: ACCESS_BITS },
{ id: role.id, allow: ACCESS_BITS },
]
for (const staffId of staffRoleIds) {
// A staff role the operator has since deleted would make Discord reject the
// WHOLE set, taking the Team's own grant down with it. Filtered here rather
// than validated on the site, which cannot see the guild's role list.
if (!guild.roles.cache.has(staffId)) {
log.warn('a configured staff role is not in this guild; skipping it', { roleId: staffId })
continue
}
overwrites.push({ id: staffId, allow: ACCESS_BITS })
}
return overwrites
}
async function ensureChannel(guild, channelId, { name, category, role, staffRoleIds }) {
const overwrites = overwritesFor(guild, role, staffRoleIds)
let channel = channelId ? await guild.channels.fetch(channelId).catch(() => null) : null
if (channel && channel.type !== ChannelType.GuildVoice) {
// Somebody pointed us at, or converted this into, something that is not a
// voice channel. Not ours to repurpose — make the right one and leave theirs.
log.warn('the stored channel is not a voice channel; making a new one', { channelId })
channel = null
}
if (!channel) {
const created = await guild.channels.create({
name,
type: ChannelType.GuildVoice,
parent: category.id,
permissionOverwrites: overwrites,
reason: 'Team voice channel',
})
log.info('created a team voice channel', { channelId: created.id, name })
return { channel: created, created: true }
}
// Overwrites are re-set on every pass rather than diffed: the set is three or
// four entries, `set` is one API call, and re-asserting it is what repairs a
// channel somebody edited by hand.
await channel.permissionOverwrites.set(overwrites, 'Team voice access')
if (channel.parentId !== category.id) {
await channel.setParent(category.id, { lockPermissions: false, reason: 'Team voice channel' })
}
if (channel.name !== name) {
await channel.setName(name, 'Team renamed').catch((err) => {
log.warn('could not rename the team voice channel', { channelId: channel.id, message: err.message })
})
}
return { channel, created: false }
}
/**
* Bring the role's member list to the site's list, up to `maxOps` changes.
*
* **Bounded, and the remainder is reported rather than dropped.** Each grant is
* its own API call under its own rate limit, so an unbounded first pass on a
* large guild is a request that outlives its own timeout — and a timeout is the
* one outcome that leaves the site not knowing what was applied. The site asks
* again until `pending` reaches zero.
*
* **A member the site names who is not in this guild is skipped silently.** They
* linked their Discord account to the site and never joined the guild, which is
* an ordinary state (§2.6 hop 3 without hop 4) and not something an operator
* needs to see a hundred of.
*/
async function syncRoleMembers(guild, role, memberIds, maxOps) {
// One fetch of the whole member list, so `role.members` and the "are they even
// here" check both read from a cache that is actually populated. discord.js
// keeps it current from gateway events afterwards; without the fetch, a bot
// that has been up for five minutes knows only the members who spoke.
await guild.members.fetch()
const desired = new Set(memberIds.map(String))
const current = new Set(role.members.map((member) => member.id))
const toAdd = [...desired].filter((id) => !current.has(id) && guild.members.cache.has(id))
const toRemove = [...current].filter((id) => !desired.has(id))
let ops = 0
let added = 0
let removed = 0
for (const id of toAdd) {
if (ops >= maxOps) break
const member = guild.members.cache.get(id)
try {
// eslint-disable-next-line no-await-in-loop
await member.roles.add(role, 'Team member')
added += 1
} catch (err) {
// One member the bot cannot touch — almost always the role hierarchy, when
// the member outranks the bot — must not cost the other forty-nine.
log.warn('could not grant the team role', { userId: id, roleId: role.id, message: err.message })
}
ops += 1
}
for (const id of toRemove) {
if (ops >= maxOps) break
const member = guild.members.cache.get(id)
if (!member) continue
try {
// eslint-disable-next-line no-await-in-loop
await member.roles.remove(role, 'No longer a team member')
removed += 1
} catch (err) {
log.warn('could not revoke the team role', { userId: id, roleId: role.id, message: err.message })
}
ops += 1
}
return { added, removed, pending: Math.max(0, toAdd.length + toRemove.length - ops) }
}
/** One Team, reconciled. */
async function syncTeamVoice(client, guildId, {
teamId, name, categoryId, channelId, roleId, staffRoleIds = [], memberIds = [], maxMemberOps = 50,
}) {
const guild = await client.guilds.fetch(guildId)
const category = await ensureCategory(guild, categoryId)
const { role, created: roleCreated } = await ensureRole(guild, roleId, name)
const { channel, created: channelCreated } = await ensureChannel(guild, channelId, {
name, category, role, staffRoleIds,
})
const members = await syncRoleMembers(guild, role, memberIds, maxMemberOps)
log.info('team voice reconciled', {
teamId, name, channelId: channel.id, roleId: role.id, ...members,
})
return {
category_id: category.id,
channel_id: channel.id,
role_id: role.id,
created: { channel: channelCreated, role: roleCreated },
members,
}
}
/**
* Remove a Team's channel and role.
*
* Both, in one call, because they are one lifecycle: deleting the channel and
* leaving the role would leave every member wearing a badge for a place that no
* longer exists. Either being already gone is success.
*/
async function removeTeamVoice(client, guildId, { channelId, roleId }) {
const guild = await client.guilds.fetch(guildId)
const result = { channel_deleted: false, role_deleted: false }
if (channelId) {
const channel = await guild.channels.fetch(channelId).catch(() => null)
if (channel) {
try {
await channel.delete('Team no longer qualifies for a voice channel')
result.channel_deleted = true
} catch (err) {
if (!isMissing(err)) throw err
}
}
}
if (roleId) {
const role = await guild.roles.fetch(roleId).catch(() => null)
if (role) {
try {
await role.delete('Team no longer qualifies for a voice channel')
result.role_deleted = true
} catch (err) {
if (!isMissing(err)) throw err
}
}
}
log.info('team voice removed', { channelId, roleId, ...result })
return result
}
module.exports = {
CATEGORY_NAME,
ACCESS_BITS,
preflight,
ensureCategory,
ensureRole,
ensureChannel,
overwritesFor,
syncRoleMembers,
syncTeamVoice,
removeTeamVoice,
}

View File

@@ -1,7 +1,5 @@
const discordManager = require('../discord/discordManager')
const newsAnnounce = require('../discord/newsAnnounce')
const teamNotify = require('../discord/teamNotify')
const teamVoice = require('../discord/teamVoice')
const modLog = require('../discord/modLog')
const createLogger = require('../utils/logger')
@@ -101,142 +99,4 @@ async function reverseModAction(req, res) {
}
}
// POST /internal/refresh-commands — the app's nudge that its registered
// slash-command set has moved (TEAMS.md §7.1). No body: the bot re-pulls
// `/internal/commands` and re-registers only if the set actually changed, so the
// nudge stays a cheap thing the app can send on every module state change.
//
// Deliberately its OWN endpoint rather than riding on /internal/config, which
// carries the decrypted bot token: saying "commands changed" should not require
// the app to read a secret out of the database.
//
// Answers 200 even when disconnected — there is no application to register
// against until the bot logs in, and `ready` pulls again anyway. A 5xx here
// would make an ordinary module install look like a failure in the admin panel.
async function refreshCommands(req, res) {
try {
const result = await discordManager.refreshCommands()
return res.json({ ok: true, ...result })
} catch (err) {
log.error('refresh-commands failed', { message: err.message })
return res.json({ ok: false, error: err.message })
}
}
// POST /internal/team-notify — a Team notification the site has already decided
// belongs in a channel (TEAMS.md §7.2). Body: { channel_id, stream, team_name,
// team_url, title, body, url }.
//
// **The site chose the channel and the site checked the access.** Whether
// members-only forum text may reach this channel is an acknowledgement recorded
// against team_integration_config, and re-deciding it here would mean the bot
// holding a copy of a policy it cannot see the inputs to.
//
// 503 when disconnected and 400 for a channel the bot cannot post to, matching
// /internal/announce — the caller is one-shot and best-effort and only logs the
// difference, but an operator debugging a silent channel needs the two to read
// differently in the bot's log.
async function teamNotifyHandler(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
const { channel_id: channelId, stream, team_name: teamName, team_url: teamUrl, title, body, url } = req.body || {}
if (!channelId || !stream) {
return res.status(400).json({ message: 'channel_id and stream are required' })
}
try {
await teamNotify.postTeamNotification(connection.client, { channelId, stream, teamName, teamUrl, title, body, url })
return res.json({ posted: true })
} catch (err) {
log.warn('team-notify failed', { message: err.message, stream, channelId })
return res.status(400).json({ message: err.message })
}
}
// ── Voice channels (TEAMS.md §7.3, phase 9) ────────────────────────────────
// GET /internal/team-voice/preflight — can this bot do the job at all?
//
// Its own endpoint, and the app asks it BEFORE letting an operator switch voice
// on. §7.3 assumed the bot could manage channels and roles; nothing in this
// project has ever checked, because the operator invites the bot by hand and
// there is no invite URL with a permission integer anywhere in the tree. Without
// this the first symptom of an unticked box is every Team recording its own
// identical error, which reads like forty problems instead of one.
async function voicePreflight(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ connected: false, message: 'Bot is not connected' })
try {
return res.json(await teamVoice.preflight(connection.client, connection.guildId))
} catch (err) {
log.warn('voice preflight failed', { message: err.message })
return res.status(400).json({ connected: true, message: err.message })
}
}
// POST /internal/team-voice/sync — make one Team's channel, role and role
// membership match what the site sent.
//
// The site sends DESIRED STATE and this works out the calls, which is the
// opposite of the split every other endpoint here uses. The decisions are all
// still the site's; what is here is the comparison against live guild state,
// which only this process can see.
async function voiceSync(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
const {
team_id: teamId, name, category_id: categoryId, channel_id: channelId, role_id: roleId,
staff_role_ids: staffRoleIds, member_ids: memberIds, max_member_ops: maxMemberOps,
} = req.body || {}
if (!name) return res.status(400).json({ message: 'name is required' })
try {
const result = await teamVoice.syncTeamVoice(connection.client, connection.guildId, {
teamId,
name,
categoryId: categoryId || null,
channelId: channelId || null,
roleId: roleId || null,
staffRoleIds: Array.isArray(staffRoleIds) ? staffRoleIds.map(String) : [],
memberIds: Array.isArray(memberIds) ? memberIds.map(String) : [],
maxMemberOps: Number(maxMemberOps) > 0 ? Number(maxMemberOps) : 50,
})
return res.json(result)
} catch (err) {
// 400 rather than 500, matching /internal/announce: from the app's side this
// is "Discord refused", which is a condition it records against the Team and
// retries next pass — not a bug in this process.
log.warn('voice sync failed', { message: err.message, teamId, name })
return res.status(400).json({ message: err.message })
}
}
// POST /internal/team-voice/remove — the grace window expired, or an admin said so.
async function voiceRemove(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
const { channel_id: channelId, role_id: roleId } = req.body || {}
try {
const result = await teamVoice.removeTeamVoice(connection.client, connection.guildId, { channelId, roleId })
return res.json(result)
} catch (err) {
log.warn('voice remove failed', { message: err.message, channelId, roleId })
return res.status(400).json({ message: err.message })
}
}
module.exports = {
setConfig,
getStatus: getStatusHandler,
announce,
reverseModAction,
refreshCommands,
teamNotify: teamNotifyHandler,
voicePreflight,
voiceSync,
voiceRemove,
}
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }

View File

@@ -11,10 +11,5 @@ router.post('/config', ctrl.setConfig)
router.get('/status', ctrl.getStatus)
router.post('/announce', ctrl.announce)
router.post('/mod-reverse', ctrl.reverseModAction)
router.post('/refresh-commands', ctrl.refreshCommands)
router.post('/team-notify', ctrl.teamNotify)
router.get('/team-voice/preflight', ctrl.voicePreflight)
router.post('/team-voice/sync', ctrl.voiceSync)
router.post('/team-voice/remove', ctrl.voiceRemove)
module.exports = router

View File

@@ -1,76 +0,0 @@
// Shared-secret client for the APP's internal listener (port 3001) — the
// bot→app direction of the channel `botInternalClient.js` runs app→bot.
//
// Two callers, both slash-command plumbing (TEAMS.md §7.1): pull the registered
// command definitions, and dispatch one that a member has just run. Distinct
// from siteApiClient.js, which reads the site's PUBLIC API with no secret at all.
//
// **The base URL is derived from `SITE_INTERNAL_URL`'s origin, not configured
// separately.** That variable already points at the app's internal listener —
// `http://app:3001/internal/bot-config` — and adding a second variable naming the
// same host would be one more thing an operator can get half-right. Deriving it
// means every existing deployment gains these endpoints with no compose change.
const createLogger = require('../utils/logger')
const log = createLogger('app-internal')
const KEY = process.env.BOT_INTERNAL_KEY || ''
// §7.1's budget, and the same 4s `botInternalClient` uses in the other
// direction. The app bounds its own handlers UNDER this (3s), so a timeout here
// normally means the app itself is unreachable rather than a module being slow.
const TIMEOUT_MS = 4000
function baseUrl() {
const configured = process.env.SITE_INTERNAL_URL
if (!configured) return null
try {
return new URL(configured).origin
} catch {
log.error('SITE_INTERNAL_URL is not a URL — slash-command registration is off', { configured })
return null
}
}
async function call(path, { method = 'GET', body } = {}) {
const base = baseUrl()
if (!base || !KEY) return { ok: false, error: 'SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set' }
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const res = await fetch(`${base}${path}`, {
method,
headers: { 'Content-Type': 'application/json', 'X-Internal-Key': KEY },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
if (!res.ok) return { ok: false, status: res.status, error: `app responded ${res.status}` }
return { ok: true, status: res.status, data: await res.json() }
} catch (err) {
log.warn('app internal call failed', { path, message: err.message })
return { ok: false, status: 0, error: err.message }
} finally {
clearTimeout(timeout)
}
}
/** The registered slash-command definitions, plus the version they belong to. */
function fetchCommands() {
return call('/internal/commands')
}
/**
* Run one command in the app and get the response envelope back.
*
* The bot has already deferred by the time this is called, so the only deadline
* that matters is Discord's 15-minute follow-up window — TIMEOUT_MS is about not
* holding an interaction open on a wedged app, not about the 3-second ack.
*/
function dispatchCommand({ command, options, platformUserId, guildId }) {
return call('/internal/commands/dispatch', {
method: 'POST',
body: { command, options, platform: 'discord', platformUserId, guildId },
})
}
module.exports = { fetchCommands, dispatchCommand }

View File

@@ -1,77 +0,0 @@
// The bot→app internal client (TEAMS.md §7.1).
//
// One property carries this file: the base URL is DERIVED from
// `SITE_INTERNAL_URL`, which already names the app's internal listener with a
// path on the end. That derivation is the reason every existing deployment gains
// slash commands with no compose change, and it is exactly the kind of string
// handling that breaks silently — a wrong base means "the app is down" forever,
// with nothing in the logs but a fetch error.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const env = { ...process.env }
const realFetch = global.fetch
beforeEach(() => {
process.env.SITE_INTERNAL_URL = 'http://app:3001/internal/bot-config'
process.env.BOT_INTERNAL_KEY = 'shh'
delete require.cache[require.resolve('../src/site/appInternalClient')]
})
afterEach(() => {
process.env = { ...env }
global.fetch = realFetch
})
/** Load the client fresh and record the single fetch it makes. */
function withFetch(response) {
const seen = {}
global.fetch = async (url, init) => {
seen.url = url
seen.init = init
return response
}
// eslint-disable-next-line global-require
return { client: require('../src/site/appInternalClient'), seen }
}
const ok = (body) => ({ ok: true, status: 200, json: async () => body })
test('the commands URL is the internal listeners origin, not its bot-config path', async () => {
const { client, seen } = withFetch(ok({ version: 3, commands: [] }))
const res = await client.fetchCommands()
assert.equal(seen.url, 'http://app:3001/internal/commands')
assert.equal(seen.init.headers['X-Internal-Key'], 'shh')
assert.deepEqual(res.data, { version: 3, commands: [] })
})
test('a dispatch names the platform, so the app never has to guess', async () => {
const { client, seen } = withFetch(ok({ ok: true, response: {} }))
await client.dispatchCommand({ command: 'guild', options: { name: 'KOC' }, platformUserId: '5', guildId: '9' })
assert.equal(seen.url, 'http://app:3001/internal/commands/dispatch')
assert.deepEqual(JSON.parse(seen.init.body), {
command: 'guild', options: { name: 'KOC' }, platform: 'discord', platformUserId: '5', guildId: '9',
})
})
// A bot with no internal URL configured is an ordinary deployment state (the
// warning already exists in bootstrap.js); it must not become an exception on
// every `ready`.
test('an unconfigured or unparseable SITE_INTERNAL_URL is a refusal, not a throw', async () => {
delete process.env.SITE_INTERNAL_URL
const { client } = withFetch(ok({}))
assert.equal((await client.fetchCommands()).ok, false)
delete require.cache[require.resolve('../src/site/appInternalClient')]
process.env.SITE_INTERNAL_URL = 'not a url'
// eslint-disable-next-line global-require
assert.equal((await require('../src/site/appInternalClient').fetchCommands()).ok, false)
})
test('a non-2xx carries its status so the caller can tell "down" from "rejected"', async () => {
const { client } = withFetch({ ok: false, status: 401, json: async () => ({}) })
const res = await client.fetchCommands()
assert.equal(res.ok, false)
assert.equal(res.status, 401)
})

View File

@@ -1,269 +0,0 @@
// ── The bot's half of module slash commands (TEAMS.md §7.1) ────────────────
//
// The first tests in this package, and they exist for a specific reason: phases
// 8 and 9 put more of the Discord integration in this process, and the failure
// modes here are ones no unit test in `server/` can see — a whole-set PUT that
// one bad entry poisons, a deferral that has to happen before anything slow, and
// a reply that must be EDITED rather than sent once the interaction is deferred.
//
// Nothing here talks to Discord. `interaction` is a fake that records what was
// called on it, which is the whole of what this file is asserting about.
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const dynamic = require('../src/discord/dynamicCommands')
const appInternal = require('../src/site/appInternalClient')
const staticCommands = require('../src/discord/commands')
const originals = {
fetchCommands: appInternal.fetchCommands,
dispatchCommand: appInternal.dispatchCommand,
get: staticCommands.get,
}
beforeEach(() => {
dynamic._reset()
Object.assign(appInternal, originals)
staticCommands.get = originals.get
})
const definition = (over = {}) => ({
name: 'guild',
description: 'Show a guild',
owner: 'uo',
access: 'everyone',
options: [{ name: 'name', type: 'string', description: 'Guild name', required: false }],
...over,
})
const answers = (commands, version = 1) => {
appInternal.fetchCommands = async () => ({ ok: true, data: { version, commands } })
}
function fakeInteraction({ commandName = 'guild', options = {}, userId = '555' } = {}) {
const calls = []
return {
calls,
commandName,
guildId: '999',
user: { id: userId },
options: {
get: (name) => (name in options ? { value: options[name] } : null),
},
deferReply: async (payload) => calls.push(['defer', payload]),
editReply: async (payload) => calls.push(['edit', payload]),
deleteReply: async () => calls.push(['delete']),
followUp: async (payload) => calls.push(['followUp', payload]),
}
}
// ── Pulling ────────────────────────────────────────────────────────────────
test('a pull reports whether the set moved, so a nudge is cheap', async () => {
answers([definition()], 7)
assert.deepEqual(await dynamic.pull(), { ok: true, changed: true, count: 1 })
// Same version, same size: nothing to re-register, and re-registering anyway
// would mean a REST.put per module state change instead of per real change.
assert.deepEqual(await dynamic.pull(), { ok: true, changed: false, count: 1 })
answers([definition()], 8)
assert.equal((await dynamic.pull()).changed, true)
})
// Otherwise a restart blip would deregister every module command from Discord
// and re-register it a minute later, with members watching it happen.
test('a failed pull keeps the set already registered', async () => {
answers([definition()])
await dynamic.pull()
appInternal.fetchCommands = async () => ({ ok: false, error: 'ECONNREFUSED' })
assert.deepEqual(await dynamic.pull(), { ok: false, changed: false, count: 1 })
assert.equal(dynamic.definitions().length, 1)
})
// The collision the app cannot see: it validates against what IT registered and
// does not know the bot's own array exists. Two entries of one name in a single
// PUT is rejected as a batch, taking the built-ins down with it.
test('a module command that collides with a built-in is dropped, not registered', async () => {
staticCommands.get = (name) => (name === 'ping' ? { data: { name: 'ping' } } : undefined)
answers([definition({ name: 'ping' }), definition()])
await dynamic.pull()
assert.deepEqual(dynamic.definitions().map((d) => d.name), ['guild'])
assert.equal(dynamic.has('ping'), false)
})
test('definitions carry Discords numeric option types, not the contracts names', async () => {
answers([definition({
options: [
{ name: 'who', type: 'user', description: 'A member', required: true },
{ name: 'n', type: 'integer', description: 'How many', choices: [{ name: 'one', value: 1 }] },
],
})])
await dynamic.pull()
const [data] = dynamic.definitions()
assert.deepEqual(data.options.map((o) => o.type), [6, 4])
assert.deepEqual(data.options[1].choices, [{ name: 'one', value: 1 }])
assert.equal(data.default_member_permissions, undefined)
})
// `linked` has no Discord equivalent — there is no "has a website account"
// predicate — so only `staff` maps, and the app re-checks both regardless.
test('only access: staff becomes a Discord permission default', async () => {
answers([definition({ access: 'staff' }), definition({ name: 'other', access: 'linked' })])
await dynamic.pull()
const [staff, linked] = dynamic.definitions()
assert.equal(typeof staff.default_member_permissions, 'string')
assert.equal(linked.default_member_permissions, undefined)
})
// ── Executing ──────────────────────────────────────────────────────────────
test('the deferral happens before the dispatch, always', async () => {
answers([definition()])
await dynamic.pull()
let deferredFirst = false
const interaction = fakeInteraction()
appInternal.dispatchCommand = async () => {
deferredFirst = interaction.calls.length === 1 && interaction.calls[0][0] === 'defer'
return { ok: true, data: { ok: true, response: { text: 'hi' } } }
}
await dynamic.execute(interaction)
assert.ok(deferredFirst, 'the website is never in Discords 3-second ack path')
assert.deepEqual(interaction.calls.at(-1), ['edit', { content: 'hi' }])
})
test('the options the member supplied are passed by name, as plain values', async () => {
answers([definition({
options: [
{ name: 'name', type: 'string', description: 'd' },
{ name: 'who', type: 'user', description: 'd' },
{ name: 'missing', type: 'string', description: 'd' },
],
})])
await dynamic.pull()
let sent = null
appInternal.dispatchCommand = async (body) => {
sent = body
return { ok: true, data: { ok: true, response: {} } }
}
await dynamic.execute(fakeInteraction({ options: { name: 'KOC', who: '42' } }))
assert.deepEqual(sent.options, { name: 'KOC', who: '42' })
assert.equal(sent.platformUserId, '555')
assert.equal(sent.guildId, '999')
})
test('a title or fields render as an embed; a bare text does not', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true,
data: { ok: true, response: { title: 'Knights', text: 'Alliance: Accord', fields: [{ name: 'Members', value: '12' }], url: 'https://site.test/uo/guilds/7' } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
const [, payload] = interaction.calls.at(-1)
assert.equal(payload.embeds[0].title, 'Knights')
assert.equal(payload.embeds[0].description, 'Alliance: Accord')
assert.equal(payload.embeds[0].url, 'https://site.test/uo/guilds/7')
})
// §9 answer 5: the public projection, plus a private nudge to link. One reply
// cannot be both, so the aside is a follow-up — which is the bot's decision to
// make, not the handler's.
test('a notice becomes an ephemeral follow-up beside a public answer', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true,
data: { ok: true, response: { text: 'public', notice: 'Link your account' } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.at(-1), ['followUp', { content: 'Link your account', ephemeral: true }])
})
test('a notice is not repeated when the answer was already private', async () => {
answers([definition({ access: 'linked' })])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true,
data: { ok: true, response: { text: 'private', notice: 'Link your account' } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls[0], ['defer', { ephemeral: true }])
assert.equal(interaction.calls.some(([kind]) => kind === 'followUp'), false)
})
// Every failure path EDITS. Replying to a deferred interaction throws, so a
// refusal that used reply() would turn a clean "no" into an unhandled error.
// Ephemerality is fixed at the DEFERRAL, which happens before the handler has
// said anything — so honouring a per-answer flag needs the deferred reply
// withdrawn. The live walk caught the version that ignored it posting "guild
// information is not shown to your account" into the channel, which announces a
// member's access level to everyone in it.
test('a handler asking for privacy gets it, even though the deferral was public', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true, data: { ok: true, response: { text: 'just for you', ephemeral: true } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
assert.deepEqual(interaction.calls.at(-1)[1], { content: 'just for you', ephemeral: true })
})
test('an already-private deferral just edits — no second message', async () => {
answers([definition({ access: 'linked' })])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true, data: { ok: true, response: { text: 'private', ephemeral: true } },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
})
// "You do not have access to that" is about one member and belongs to one
// member, whatever the command's usual privacy.
test('a refusal is always private', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({ ok: true, data: { ok: false, reason: 'forbidden' } })
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'delete', 'followUp'])
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
})
test('a refusal is phrased by the bot and edited into the deferred reply', async () => {
answers([definition({ access: 'linked' })])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({
ok: true, data: { ok: false, reason: 'forbidden', access: 'linked', isLinked: false },
})
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.match(interaction.calls.at(-1)[1].content, /Link your Discord account/)
// Deferred ephemerally (access: 'linked'), so the refusal is one edit and no
// withdrawal — replying twice to a deferred interaction is what throws.
assert.deepEqual(interaction.calls.map(([kind]) => kind), ['defer', 'edit'])
})
test('an unreachable app is the same sentence to the member and a different line in the log', async () => {
answers([definition()])
await dynamic.pull()
appInternal.dispatchCommand = async () => ({ ok: false, error: 'timeout' })
const interaction = fakeInteraction()
await dynamic.execute(interaction)
assert.match(interaction.calls.at(-1)[1].content, /Something went wrong/)
assert.equal(interaction.calls.at(-1)[1].ephemeral, true)
})
test('an interaction for a command the app no longer serves is left alone', async () => {
answers([definition()])
await dynamic.pull()
const interaction = fakeInteraction({ commandName: 'gone' })
assert.equal(await dynamic.execute(interaction), false)
assert.deepEqual(interaction.calls, [], 'nothing is deferred for a command that is not ours')
})

View File

@@ -1,138 +0,0 @@
// ── The bot's half of the Team notifications bridge (TEAMS.md §7.2) ────────
//
// Nothing here talks to Discord. `channel` is a fake that records what was sent,
// and the assertions are about the three things this side genuinely owns:
//
// 1. **the channel comes from the app and is never looked up.** `newsAnnounce`
// reads guild_config because there is one #news; a Team's destination is
// per-Team configuration, and a bot that resolved it would hold a second
// copy of a table it cannot see the inputs to;
// 2. **a channel the bot cannot post to fails loudly rather than silently.** A
// caller that is one-shot and best-effort only logs the difference, but an
// operator debugging a quiet channel needs the bot's log to distinguish
// "not connected" from "that id is not a text channel";
// 3. **Discord's own limits are enforced here.** An embed that exceeds them is
// rejected WHOLESALE, so a long forum body must be truncated on this side
// even though the app already excerpted it — the app's limit is a product
// decision and this one is a protocol constraint.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const teamNotify = require('../src/discord/teamNotify')
// A fake channel that records what it was sent. `isTextBased` is the one method
// the code branches on, so it is the one worth making configurable.
function fakeChannel({ textBased = true } = {}) {
const sends = []
return {
sends,
isTextBased: () => textBased,
send: async (payload) => { sends.push(payload); return { id: 'm1' } },
}
}
function fakeClient(channel, { throws = false } = {}) {
return {
channels: {
fetch: async (id) => {
if (throws) throw new Error('Unknown Channel')
return id === 'chan-1' ? channel : null
},
},
}
}
const post = (client, over = {}) => teamNotify.postTeamNotification(client, {
channelId: 'chan-1',
stream: 'team.forum.post',
teamName: 'Blackthorns Legion',
teamUrl: 'https://site/guilds/blackthorns-legion',
title: 'Siege tonight',
body: 'Meet at the moongate.',
url: 'https://site/guilds/blackthorns-legion?thread=41',
...over,
})
// ── 1. The channel is the app's decision ───────────────────────────────────
test('the message goes to the channel the app named', async () => {
const channel = fakeChannel()
await post(fakeClient(channel))
assert.equal(channel.sends.length, 1)
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title, 'Siege tonight')
assert.equal(embed.data.author.name, 'Blackthorns Legion')
assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41')
})
test('no channel id at all is refused before anything is fetched', async () => {
await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/)
})
// ── 2. A channel the bot cannot use ────────────────────────────────────────
test('a channel the bot cannot see is a clear error, not a silent no-op', async () => {
await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/)
})
test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => {
await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/)
})
test('a voice channel is refused', async () => {
await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/)
})
// ── 3. Discord's limits, and the heading ───────────────────────────────────
test('an over-long title is truncated rather than rejected by Discord as a whole', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { title: 'y'.repeat(400) })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title.length, teamNotify.TITLE_MAX)
assert.ok(embed.data.title.endsWith('…'))
})
test('an over-long body is truncated to the description limit', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { body: 'z'.repeat(9000) })
const [embed] = channel.sends[0].embeds
assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32)
})
test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { stream: 'team.announcement' })
const [embed] = channel.sends[0].embeds
assert.match(embed.data.description, /^\*\*Announcement\*\*/)
assert.match(embed.data.description, /Meet at the moongate\./)
})
test('a roster event has no title, so the heading becomes the title', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title, 'New member')
assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one')
})
test('an unknown stream still posts, under a neutral heading', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { stream: 'team.something.new', title: null })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.title, 'Team update')
})
test('a missing team name does not produce an embed with an empty author line', async () => {
const channel = fakeChannel()
await post(fakeClient(channel), { teamName: '', teamUrl: null })
const [embed] = channel.sends[0].embeds
assert.equal(embed.data.author.name, 'A team')
assert.equal(embed.data.author.url, undefined)
})
test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => {
assert.equal(teamNotify.clamp(' ', 100), null)
assert.equal(teamNotify.clamp('ok', 100), 'ok')
})

View File

@@ -1,364 +0,0 @@
// ── The bot's half of Team voice channels (TEAMS.md §7.3, phase 9) ────────
//
// Nothing here talks to Discord. `fakeGuild` records the calls, and the
// assertions are about the four things this side genuinely owns — the ones the
// site cannot decide because it cannot see the guild:
//
// 1. **The overwrite set.** @everyone denied, the Team's role allowed, each
// configured staff role allowed — and a staff role the operator has since
// deleted is FILTERED, because Discord rejects the whole set for one bad id
// and that would take the Team's own grant down with it.
// 2. **The membership diff is bounded and the remainder is reported.** Each
// grant is its own API call; an unbounded first pass on a large guild
// outlives its own timeout, which is the one failure that leaves the site
// not knowing what was applied.
// 3. **A member who linked Discord but never joined the guild is skipped
// silently.** That is §2.6 hop 3 without hop 4 — an ordinary state, not an
// error, and certainly not a hundred log lines.
// 4. **A missing target is success.** A teardown that finds its channel already
// deleted has reached the desired end state; a sync that finds one deleted
// simply creates it again.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { ChannelType, PermissionFlagsBits } = require('discord.js')
const teamVoice = require('../src/discord/teamVoice')
const EVERYONE = 'guild-everyone'
function fakeMember(id, { canGrant = true } = {}) {
const roles = new Set()
return {
id,
roles: {
cache: roles,
add: async (role) => {
if (!canGrant) throw new Error('Missing Permissions')
roles.add(role.id)
},
remove: async (role) => { roles.delete(role.id) },
},
}
}
function fakeGuild({
members = [],
roles = [],
channels = [],
botPermissions = [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageRoles],
} = {}) {
const memberMap = new Map(members.map((m) => [m.id, m]))
const roleMap = new Map(roles.map((r) => [r.id, r]))
const channelMap = new Map(channels.map((c) => [c.id, c]))
const created = { roles: [], channels: [] }
let nextId = 1000
const guild = {
id: 'guild-1',
created,
roles: {
everyone: { id: EVERYONE },
cache: roleMap,
fetch: async (id) => roleMap.get(id) || null,
create: async (opts) => {
const role = {
id: String(nextId++),
name: opts.name,
members: [],
setName: async (name) => { role.name = name },
delete: async () => { roleMap.delete(role.id) },
}
roleMap.set(role.id, role)
created.roles.push(opts)
return role
},
},
channels: {
cache: channelMap,
fetch: async (id) => channelMap.get(id) || null,
create: async (opts) => {
const channel = {
id: String(nextId++),
name: opts.name,
type: opts.type,
parentId: opts.parent || null,
overwrites: opts.permissionOverwrites || [],
permissionOverwrites: {
set: async (list) => { channel.overwrites = list },
},
setParent: async (parentId) => { channel.parentId = parentId },
setName: async (name) => { channel.name = name },
delete: async () => { channelMap.delete(channel.id) },
}
channelMap.set(channel.id, channel)
created.channels.push(opts)
return channel
},
},
members: {
me: { permissions: { has: (bit) => botPermissions.includes(bit) }, roles: { highest: { position: 7 } } },
cache: memberMap,
fetch: async () => memberMap,
},
}
return guild
}
const fakeClient = (guild) => ({ guilds: { fetch: async () => guild } })
const voiceChannel = (id, over = {}) => {
const channel = {
id,
name: 'The Silver Hand',
type: ChannelType.GuildVoice,
parentId: '500',
overwrites: [],
permissionOverwrites: { set: async (list) => { channel.overwrites = list } },
setParent: async (parentId) => { channel.parentId = parentId },
setName: async (name) => { channel.name = name },
delete: async () => {},
...over,
}
return channel
}
const category = (id = '500') => ({ id, type: ChannelType.GuildCategory })
const role = (id, name = 'The Silver Hand', members = []) => {
const r = {
id,
name,
members,
setName: async (next) => { r.name = next },
delete: async () => {},
}
return r
}
// ── Preflight ──────────────────────────────────────────────────────────────
test('preflight reports both permissions and the guild-wide role count', async () => {
const guild = fakeGuild({ roles: [role('1'), role('2')] })
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
assert.equal(result.can_manage_channels, true)
assert.equal(result.can_manage_roles, true)
// The GUILD's roles, not ours. The 250 cap is shared with everything the
// operator made themselves, so counting only ours would promise headroom that
// is not there.
assert.equal(result.role_count, 2)
assert.equal(result.bot_role_position, 7)
})
test('preflight reports a missing permission rather than throwing', async () => {
const guild = fakeGuild({ botPermissions: [PermissionFlagsBits.ManageChannels] })
const result = await teamVoice.preflight(fakeClient(guild), 'guild-1')
assert.equal(result.can_manage_channels, true)
assert.equal(result.can_manage_roles, false)
})
// ── Overwrites ─────────────────────────────────────────────────────────────
test('the overwrite set denies @everyone and allows the Team role', () => {
const guild = fakeGuild()
const list = teamVoice.overwritesFor(guild, role('900'), [])
assert.equal(list.length, 2)
assert.equal(list[0].id, EVERYONE)
assert.deepEqual(list[0].deny, teamVoice.ACCESS_BITS)
assert.equal(list[1].id, '900')
assert.deepEqual(list[1].allow, teamVoice.ACCESS_BITS)
})
test('a configured staff role that still exists gets an allow', () => {
const staff = role('777', 'Moderators')
const guild = fakeGuild({ roles: [staff] })
const list = teamVoice.overwritesFor(guild, role('900'), ['777'])
assert.equal(list.length, 3)
assert.equal(list[2].id, '777')
})
test('a staff role deleted in Discord is skipped, not sent — it would void the whole set', () => {
const guild = fakeGuild({ roles: [] })
const list = teamVoice.overwritesFor(guild, role('900'), ['deleted-1'])
assert.equal(list.length, 2)
assert.ok(!list.some((o) => o.id === 'deleted-1'))
})
// ── Ensure ─────────────────────────────────────────────────────────────────
test('a missing category is created; an existing one is reused', async () => {
const guild = fakeGuild()
const made = await teamVoice.ensureCategory(guild, null)
assert.equal(guild.created.channels.length, 1)
assert.equal(guild.created.channels[0].type, ChannelType.GuildCategory)
const again = await teamVoice.ensureCategory(guild, made.id)
assert.equal(again.id, made.id)
assert.equal(guild.created.channels.length, 1)
})
test('a category id pointing at something that is not a category makes a new one', async () => {
const guild = fakeGuild({ channels: [voiceChannel('700')] })
await teamVoice.ensureCategory(guild, '700')
assert.equal(guild.created.channels.length, 1)
})
test('the Team role is created not mentionable and not hoisted', async () => {
const guild = fakeGuild()
const { role: made, created } = await teamVoice.ensureRole(guild, null, 'The Silver Hand')
assert.equal(created, true)
assert.equal(made.name, 'The Silver Hand')
// A Team with two hundred members must not become a way to ping them all, or a
// second copy of the member list down the sidebar.
assert.equal(guild.created.roles[0].mentionable, false)
assert.equal(guild.created.roles[0].hoist, false)
})
test('a renamed Team renames its role rather than making a second', async () => {
const existing = role('900', 'Old Name')
const guild = fakeGuild({ roles: [existing] })
const { role: made, created } = await teamVoice.ensureRole(guild, '900', 'New Name')
assert.equal(created, false)
assert.equal(made.name, 'New Name')
assert.equal(guild.created.roles.length, 0)
})
test('a rename Discord refuses does not fail the pass — access matters more than a label', async () => {
const existing = role('900', 'Old Name')
existing.setName = async () => { throw new Error('rate limited') }
const guild = fakeGuild({ roles: [existing] })
const { role: made } = await teamVoice.ensureRole(guild, '900', 'New Name')
assert.equal(made.id, '900')
})
test('a channel a human deleted is simply created again', async () => {
const guild = fakeGuild()
const { channel, created } = await teamVoice.ensureChannel(guild, 'gone-1', {
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
})
assert.equal(created, true)
assert.equal(channel.type, ChannelType.GuildVoice)
assert.equal(channel.parentId, '500')
})
test('an existing channel has its overwrites re-asserted every pass', async () => {
const existing = voiceChannel('600')
const guild = fakeGuild({ channels: [existing] })
const { created } = await teamVoice.ensureChannel(guild, '600', {
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
})
assert.equal(created, false)
// Re-setting rather than diffing is what repairs a channel somebody edited by
// hand.
assert.equal(existing.overwrites.length, 2)
})
test('a channel that is no longer a voice channel is left alone and a new one made', async () => {
const text = voiceChannel('600', { type: ChannelType.GuildText })
const guild = fakeGuild({ channels: [text] })
const { channel, created } = await teamVoice.ensureChannel(guild, '600', {
name: 'The Silver Hand', category: category(), role: role('900'), staffRoleIds: [],
})
assert.equal(created, true)
assert.notEqual(channel.id, '600')
})
// ── Membership ─────────────────────────────────────────────────────────────
test('the role is granted to the members the site named', async () => {
const alice = fakeMember('a')
const bob = fakeMember('b')
const guild = fakeGuild({ members: [alice, bob] })
const teamRole = role('900', 'The Silver Hand', [])
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a', 'b'], 50)
assert.equal(result.added, 2)
assert.equal(result.removed, 0)
assert.equal(result.pending, 0)
})
test('a member who left the Team has the role taken away', async () => {
const alice = fakeMember('a')
const bob = fakeMember('b')
const guild = fakeGuild({ members: [alice, bob] })
const teamRole = role('900', 'The Silver Hand', [alice, bob])
const result = await teamVoice.syncRoleMembers(guild, teamRole, ['a'], 50)
assert.equal(result.added, 0)
assert.equal(result.removed, 1)
})
test('a member who linked Discord but never joined the guild is skipped without an error', async () => {
const guild = fakeGuild({ members: [] })
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['not-in-guild'], 50)
assert.equal(result.added, 0)
assert.equal(result.pending, 0)
})
test('the diff is bounded and the remainder is REPORTED, not dropped', async () => {
const members = Array.from({ length: 10 }, (_, i) => fakeMember(`m${i}`))
const guild = fakeGuild({ members })
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), members.map((m) => m.id), 4)
assert.equal(result.added, 4)
assert.equal(result.pending, 6)
})
test('one member the bot cannot touch does not cost the other forty-nine', async () => {
const ok1 = fakeMember('a')
const nope = fakeMember('b', { canGrant: false })
const ok2 = fakeMember('c')
const guild = fakeGuild({ members: [ok1, nope, ok2] })
const result = await teamVoice.syncRoleMembers(guild, role('900', 'x', []), ['a', 'b', 'c'], 50)
assert.equal(result.added, 2)
})
// ── Teardown ───────────────────────────────────────────────────────────────
test('a teardown deletes the channel and the role together', async () => {
const channel = voiceChannel('600')
const teamRole = role('900')
let deletedChannel = false
let deletedRole = false
channel.delete = async () => { deletedChannel = true }
teamRole.delete = async () => { deletedRole = true }
const guild = fakeGuild({ channels: [channel], roles: [teamRole] })
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: '600', roleId: '900' })
assert.equal(deletedChannel, true)
assert.equal(deletedRole, true)
assert.equal(result.channel_deleted, true)
assert.equal(result.role_deleted, true)
})
test('a teardown whose target is already gone is success, not a failure to retry forever', async () => {
const guild = fakeGuild({ channels: [], roles: [] })
const result = await teamVoice.removeTeamVoice(fakeClient(guild), 'guild-1', { channelId: 'gone', roleId: 'gone' })
assert.equal(result.channel_deleted, false)
assert.equal(result.role_deleted, false)
})
// ── The whole thing ────────────────────────────────────────────────────────
test('a first sync creates the category, the role and the channel, and grants the members', async () => {
const alice = fakeMember('a')
const guild = fakeGuild({ members: [alice] })
const result = await teamVoice.syncTeamVoice(fakeClient(guild), 'guild-1', {
teamId: 1,
name: 'The Silver Hand',
categoryId: null,
channelId: null,
roleId: null,
staffRoleIds: [],
memberIds: ['a'],
maxMemberOps: 50,
})
assert.equal(result.created.channel, true)
assert.equal(result.created.role, true)
assert.ok(result.category_id)
assert.ok(result.channel_id)
assert.ok(result.role_id)
assert.equal(result.members.added, 1)
})

View File

@@ -6,7 +6,6 @@ import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx'
import { routesFor } from './modules/registry.js'
import { ModuleFeaturesProvider } from './modules/features.jsx'
// Public
import Portal from './routes/public/Portal.jsx'
@@ -17,10 +16,17 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Events from './routes/public/Events.jsx'
import EventPage from './routes/public/EventPage.jsx'
import EventSeries from './routes/public/EventSeries.jsx'
import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
import Leaderboards from './routes/public/Leaderboards.jsx'
import Market from './routes/public/Market.jsx'
import MarketVendor from './routes/public/MarketVendor.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -40,309 +46,205 @@ 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 ShardVisibility from './routes/admin/views/ShardVisibility.jsx'
import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx'
import ShardOps from './routes/admin/views/ShardOps.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 UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
import EngagementRules from './routes/admin/views/EngagementRules.jsx'
import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
import EventsAdmin from './routes/admin/views/EventsAdmin.jsx'
import EventsCalendar from './routes/admin/views/EventsCalendar.jsx'
import EventEditor from './routes/admin/views/EventEditor.jsx'
import EventRun from './routes/admin/views/EventRun.jsx'
import EventActions from './routes/admin/views/EventActions.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
import Appeals from './routes/admin/views/Appeals.jsx'
import ContentReports from './routes/admin/views/ContentReports.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx'
import ForgotPassword from './routes/player/ForgotPassword.jsx'
import ResetPassword from './routes/player/ResetPassword.jsx'
import VerifyEmail from './routes/player/VerifyEmail.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.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'
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
import PlayerInbox from './routes/player/PlayerInbox.jsx'
import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
import PlayerEvents from './routes/player/PlayerEvents.jsx'
export default function App() {
return (
<AuthProvider>
<SiteProvider>
{/* Inside the auth and site contexts, because a feature provider is a
hook that may well read either — a live-status one does, indirectly,
by asking an endpoint whose answer depends on the session. Outside the
routes, so the nav in every layout is filtered by the same gate and
the provider hooks are called once for the whole app rather than
once per screen. */}
<ModuleFeaturesProvider>
<Routes>
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
<Route path="/" element={<Portal />} />
<Routes>
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
<Route path="/" element={<Portal />} />
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
<Route
element={
<MaintenanceGate>
<Outlet />
</MaintenanceGate>
}
>
<Route path="/site" element={<Website />} />
<Route path="/site/news" element={<News />} />
<Route path="/site/screenshots" element={<Screenshots />} />
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
<Route path="/site/newsletter" element={<Newsletter />} />
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
<Route path="/site/status" element={<Status />} />
<Route path="/site/shard" element={<Shard />} />
<Route path="/site/shard/activity" element={<ShardActivity />} />
<Route path="/site/champs" element={<ChampSpawns />} />
<Route path="/site/guilds" element={<Guilds />} />
<Route path="/site/governors" element={<Governors />} />
<Route path="/site/houses" element={<Houses />} />
<Route path="/site/rules" element={<Rules />} />
<Route path="/site/leaderboards" element={<Leaderboards />} />
<Route path="/site/market" element={<Market />} />
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* Installed modules' public pages, namespaced `/<id>/…` (§2.8).
Declared BEFORE the /:slug CMS catch-all: React Router ranks
static segments over dynamic ones so the order is not what saves
us, but keeping them adjacent makes the relationship visible. */}
{routesFor('public').map((r) => (
<Route key={r.path} path={`/${r.path}`} element={r.element} />
))}
{/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} />
</Route>
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
preview link works regardless of site mode. */}
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
{/* Admin */}
<Route path="/admin/login" element={<AdminLogin />} />
<Route
path="/admin"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="posts" element={<PostsAdmin />} />
<Route path="pages" element={<PagesAdmin />} />
<Route path="pages/new" element={<PageBuilder />} />
<Route path="pages/:id" element={<PageBuilder />} />
<Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} />
{/* Theme editing writes an admin-only settings key; the route sits
behind the same RoleGate as the sidebar entry that reaches it,
and PUT/DELETE /admin/settings is admin-only server-side too. */}
<Route
path="appearance"
element={
<MaintenanceGate>
<RoleGate roles={['admin']}>
<AppearanceAdmin />
</RoleGate>
}
/>
{/* Same reasoning as Appearance: the nav overrides are an admin-only
settings key, so the route carries the same RoleGate as the
sidebar entry that reaches it. */}
<Route
path="navigation"
element={
<RoleGate roles={['admin']}>
<NavEditor />
</RoleGate>
}
/>
<Route path="settings" element={<SettingsAdmin />} />
<Route
path="moderation"
element={
<RoleGate roles={['admin', 'moderator']}>
<Outlet />
</MaintenanceGate>
</RoleGate>
}
>
<Route path="/site" element={<Website />} />
<Route path="/site/news" element={<News />} />
<Route path="/site/screenshots" element={<Screenshots />} />
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
<Route path="/site/newsletter" element={<Newsletter />} />
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
{/* Events (Phase 14a). `series/:slug` is declared before `:slug`
although it could not be shadowed by it — two segments against
one. It stays above because the ranking surprise this feature
has already shipped once was exactly here: a static segment
outranks a dynamic one whatever the source order, which is what
made `/admin/events/new` unreachable from Phase 6 to Phase 13.
Nothing static shares a segment with `:slug`, so nothing here
repeats it. */}
<Route path="/site/events" element={<Events />} />
<Route path="/site/events/series/:slug" element={<EventSeries />} />
<Route path="/site/events/:slug" element={<EventPage />} />
<Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* Installed modules' public pages, namespaced `/<id>/…` — the
registry prefixes the segment, so a module cannot spell its way
out of it (docs/website/MODULE_API.md §3.3). Declared before the
CMS catch-all below: React Router ranks a static segment over a
dynamic one, so the order is not what saves us, but keeping the
two adjacent makes the relationship visible to whoever adds the
next route here. */}
{routesFor('public').map((r) => (
<Route key={r.path} path={`/${r.path}`} element={r.element} />
))}
{/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} />
<Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} />
</Route>
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
preview link works regardless of site mode. */}
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
{/* Admin */}
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="shard" element={<ShardAdmin />} />
<Route path="shard-visibility" element={<ShardVisibility />} />
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
<Route
path="/admin"
path="shard-ops"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
<RoleGate roles={['admin', 'moderator']}>
<ShardOps />
</RoleGate>
}
>
<Route index element={<Dashboard />} />
<Route path="posts" element={<PostsAdmin />} />
<Route path="pages" element={<PagesAdmin />} />
<Route path="pages/new" element={<PageBuilder />} />
<Route path="pages/:id" element={<PageBuilder />} />
<Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} />
{/* Theme editing writes an admin-only settings key; the route sits
behind the same RoleGate as the sidebar entry that reaches it,
and PUT/DELETE /admin/settings is admin-only server-side too. */}
<Route
path="appearance"
element={
<RoleGate roles={['admin']}>
<AppearanceAdmin />
</RoleGate>
}
/>
{/* Same reasoning as Appearance: the nav overrides are an admin-only
settings key, so the route carries the same RoleGate as the
sidebar entry that reaches it. */}
<Route
path="navigation"
element={
<RoleGate roles={['admin']}>
<NavEditor />
</RoleGate>
}
/>
<Route path="settings" element={<SettingsAdmin />} />
<Route
path="moderation"
element={
<RoleGate roles={['admin', 'moderator']}>
<Outlet />
</RoleGate>
}
>
<Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} />
<Route path="reports" element={<ContentReports />} />
</Route>
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
{/* Core's own screen, and it has to be: it is how a module reaches
the volume in the first place. Declared here with the rest of
core's routes, above the module-supplied ones below. */}
<Route path="modules" element={<ModulesAdmin />} />
{/* Staff-wide, like the moderation queues: the gate on the three
actions that publish a game-written name is applied per request
on the server, from the caller's live role (TEAMS.md 2.9). */}
<Route path="teams" element={<TeamsAdmin />} />
{/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement:
§K makes every read here `staff`, and the moderator's whole
power over this feature is the run console — cancelling a run
that is doing something wrong at 2am. The narrower gates are
applied per action instead: authoring is admin+editor, publish
and start are admin only (§N2), and each button follows the
route it calls. `runs/:runId` is declared before `:id` so the
literal segment is never read as a definition id. */}
<Route path="events" element={<EventsAdmin />} />
<Route path="events/calendar" element={<EventsCalendar />} />
{/* The switchboard (Phase 6). A literal segment, declared before
`events/:id` the way the router declares `/actions` before
`/:id` — the same collision, on the other side of the wire. */}
<Route path="events/actions" element={<EventActions />} />
<Route path="events/runs/:runId" element={<EventRun />} />
{/* ONE route, and `new` is a value of `:id` rather than a
path beside it. A static `events/new` outranks the dynamic
segment in React Router whatever the order, so the editor
was handed no `id` at all and asked the API for
`/admin/events/undefined`. */}
<Route path="events/:id" element={<EventEditor />} />
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
server: every route under /admin/engagement re-gates to `admin`
on top of the group's staff gate, because this is the group that
decides who receives mail. */}
<Route
path="engagement"
element={
<RoleGate roles={['admin']}>
<Outlet />
</RoleGate>
}
>
<Route index element={<Navigate to="rules" replace />} />
<Route path="rules" element={<EngagementRules />} />
<Route path="audiences" element={<EngagementAudiences />} />
<Route path="templates" element={<EngagementTemplates />} />
<Route path="triggers" element={<EngagementTriggers />} />
<Route path="sends" element={<EngagementSendLog />} />
<Route path="suppressions" element={<EngagementSuppressions />} />
<Route path="retention" element={<EngagementRetention />} />
</Route>
<Route path="account" element={<AccountAdmin />} />
{/* Staff have an inbox and channel preferences like anyone else —
`/auth/me/notifications` is behind requireAuth only — but
`RequirePlayer` sends them out of the player portal, so the two
screens are mounted here as well. Same components, same API,
two paths; `lib/notificationPaths.js` is the one mapping. */}
<Route path="notifications" element={<PlayerInbox />} />
<Route path="notifications/settings" element={<PlayerNotifications />} />
{/* And participation history, for the same reason and by the same
arrangement (Phase 14a): `/player/events/history` is behind
requireAuth alone, so a staff member has one — but
`RequirePlayer` sends them out of `/account`. Declared BEFORE
`events/:id`, though it need not be: a static segment outranks
a dynamic one whatever the order, which is the rule that made
`events/new` unreachable for seven phases. Written in the order
it resolves. */}
<Route path="events/mine" element={<PlayerEvents />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
same RoleGate its own routes above use, so the sidebar and the
route table cannot disagree about who may see what. Before the
`*` redirect, which would otherwise swallow every one of them. */}
{routesFor('admin').map((r) => (
<Route
key={r.path}
path={r.path}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
{/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} />
<Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} />
{/* Opened from a mailbox, so public like the reset page above — the
token is the proof, and confirming issues no session. */}
<Route path="/account/verify-email/:token" element={<VerifyEmail />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
{/* PUBLIC, and grouped with the other tokened landings above rather
than with the portal below: the person following an unsubscribe
link is reading their mail, not signed in (TEAMS.md §6.4). */}
<Route path="/unsubscribe/:token" element={<Unsubscribe />} />
/>
<Route
path="houses"
element={
<RequirePlayer>
<PlayerPortalLayout />
</RequirePlayer>
<RoleGate roles={['admin', 'moderator']}>
<HousesAdmin />
</RoleGate>
}
>
{/* The portal index resolves to the first nav row this viewer can
reach rather than naming a page: `PlayerCharacters` was a UO
page and left with the client half (MODULE_SYSTEM.md §2.7.1).
With the UO module installed that is still Characters. */}
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
{/* Participation history (Phase 14a). Under /account rather than
/player because it is role-agnostic self-service: staff are a
superset of players and an admin reading their own attendance
is as ordinary as anyone else doing it. */}
<Route path="/account/events" element={<PlayerEvents />} />
{/* The inbox took `/account/notifications` in engagement Phase 7
and the preferences screen moved under it. Content and
settings are different kinds of thing, and the plain word
belongs to the one a person means when they say it — which is
also what the bell in the header opens. The server's routes
split at the same place. */}
<Route path="/account/notifications" element={<PlayerInbox />} />
<Route path="/account/notifications/settings" element={<PlayerNotifications />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one
place the three areas do not read alike. */}
{routesFor('player').map((r) => (
<Route
key={r.path}
path={`/player/${r.path}`}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
</Route>
/>
<Route path="characters" element={<AdminCharacters />} />
<Route path="characters/:serial" element={<AdminCharacter />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles } that core applies as the
same RoleGate its own routes use (MODULE_API.md §3.3). */}
{routesFor('admin').map((r) => (
<Route
key={r.path}
path={r.path}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</ModuleFeaturesProvider>
{/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} />
<Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
<Route
element={
<RequirePlayer>
<PlayerPortalLayout />
</RequirePlayer>
}
>
<Route path="/player" element={<PlayerCharacters />} />
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SiteProvider>
</AuthProvider>
)

View File

@@ -42,19 +42,15 @@ function safeParse(text) {
}
}
// The request PRIMITIVE, exported for installed modules and handed to them on
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
// on a non-2xx — and nothing above them: a module owns the paths it calls,
// because it owns the routes at the other end.
// The request PRIMITIVE, exported for installed modules (window.__rg.api — see
// docs/website/MODULE_API.md §3.5). A module owns the paths it calls, because it
// owns the routes at the other end; core owns only the fetch semantics —
// same-origin /api/v1, cookies included, JSON in/out, ApiError on non-2xx.
//
// The `api` object below is core's own binding surface and nothing else: every
// namespace in it belongs to a route core still serves. A module binds its own
// paths in its own chunk, against this primitive.
// `BASE` goes with it: a module that needs an EventSource URL cannot go through
// `req` (fetch-only) and must not hardcode `/api/v1`, which is core's choice of
// mount point and not a promise it has made.
export { req as request, BASE }
// `api` below stays core's own binding surface. Its `atlas` and `shard`
// namespaces are module bindings that only still live here because Phase 3 has
// not moved them yet.
export { req as request }
export const api = {
// ----- auth -----
@@ -105,36 +101,6 @@ export const api = {
revokeTrustedDevice: (id) =>
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
// Self-service account security, role-agnostic under /auth/me/account. This is
// the ONLY surface for it: the /admin/account/* and /player/account/* copies
// were deleted (both were strictly smaller — neither carried recovery codes),
// which is why recovery codes below already lived here while the rest did not.
// The change endpoints re-issue the session cookie server-side, so the caller
// stays signed in.
myAccount: () => req('/auth/me/account'),
changeUsername: (username) =>
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
// Email address (engagement Phase 1b). changeEmail STAGES the address — the
// account keeps its current one until the emailed link is opened — so the UI
// must show `email_pending` as pending, never as the address in force.
changeEmail: (email, currentPassword) =>
req('/auth/me/account/email', { method: 'PATCH', body: { email, currentPassword } }),
resendEmailVerification: () => req('/auth/me/account/email/resend', { method: 'POST' }),
cancelEmailChange: () => req('/auth/me/account/email/pending', { method: 'DELETE' }),
// The confirm half is public and token-gated — it is reached from a mailbox,
// often with no session, so it deliberately sits outside /auth/me.
lookupEmailVerification: (token) => req(`/auth/email/verify/${encodeURIComponent(token)}`),
confirmEmailVerification: (token) =>
req(`/auth/email/verify/${encodeURIComponent(token)}`, { method: 'POST' }),
totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
myIdentities: () => req('/auth/me/account/identities'),
unlinkIdentity: (provider) =>
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
// returned ONCE (password step-up for accounts that have a password).
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
@@ -163,117 +129,6 @@ export const api = {
return req(`/public/wiki${withQs(s)}`)
},
wikiCategories: () => req('/public/wiki/categories'),
// ----- Teams (TEAMS.md §2.11, §4.3) -----
//
// Only the two calls CORE's own client makes. Core renders no Team pages — the
// vocabulary belongs to whichever module owns the surface — so the index, the
// roster and the player list are not here; a module that renders those calls
// the same public API from its own client.
//
// The lookup exists because a module names a Team in its own terms and core
// keys the feed by slug. Resolving that is core's job precisely so a module
// never has to hold core's identifiers.
teamByExternalId: (moduleId, externalId) =>
req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
teamActivity: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit != null) qs.set('limit', String(opts.limit))
if (opts.offset != null) qs.set('offset', String(opts.offset))
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
},
// The Team FORUM, under /player because a participant may be a plain player and
// a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
// core's: only core resolves whether this viewer is inside the Team, and the
// member/guest split is a security boundary. The module renders the PLACE.
teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
teamForumPost: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
teamForumModerate: (slug, id, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
// Phase 5 ("5b"). A reply, an edit and post-level moderation are separate
// routes from their thread-level cousins rather than the same route with a
// target kind, because they answer to different rules: a reply is refused by a
// lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all.
teamForumReply: (slug, threadId, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }),
teamForumEditPost: (slug, postId, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }),
teamForumModeratePost: (slug, postId, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }),
// The report goes to SITE STAFF, never to the Team's leaders — the whole point
// of it is a path that routes around a Team's own leadership (TEAMS.md §5.6).
// There is no leader-facing counterpart to this call and there should not be.
teamForumReport: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }),
teamForumUpload: (slug, file) => {
const fd = new FormData()
fd.append('image', file)
return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
},
teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
teamGrantAdd: (slug, body) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
teamGrantRevoke: (slug, userId) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
// ----- notifications (TEAMS.md Part 6) -----
//
// Under /auth/me rather than /player: these are role-agnostic self-service, the
// same rule that put the forum under /player rather than behind a staff gate.
// The streams catalog and the per-stream subscriptions were built for the app
// and had no web consumer at all until phase 6 gave them one.
notificationStreams: () => req('/auth/me/notifications/streams'),
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
// `streams` is always sent, empty array included — the endpoint requires the
// field, so clearing the last subscription must not become an absent key.
setNotificationSubscriptions: (streams) =>
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
// Per-channel preferences (ENGAGEMENT.md Phase 3). A SPARSE update: only the
// (id, channel) pairs sent are written, so a screen managing one channel need
// not know what the others hold. Shipped with no surface at all until Phase 7.
notificationChannelPrefs: () => req('/auth/me/notifications/channels'),
setNotificationChannelPrefs: (prefs) =>
req('/auth/me/notifications/channels', { method: 'PUT', body: { prefs } }),
// The in-app inbox (ENGAGEMENT.md Phase 7). `before` is a keyset cursor — the
// id of the last item on the previous page — not an offset: the list gains
// rows at the top while it is being read.
notifications: ({ limit, before, unread } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (before) qs.set('before', String(before))
if (unread) qs.set('unread', 'true')
return req(`/auth/me/notifications${withQs(qs.toString())}`)
},
notificationsUnreadCount: () => req('/auth/me/notifications/unread-count'),
markNotificationRead: (id) => req(`/auth/me/notifications/${id}/read`, { method: 'POST' }),
markAllNotificationsRead: () => req('/auth/me/notifications/read-all', { method: 'POST' }),
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
setTeamNotificationPrefs: (teams) =>
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
// Unauthenticated, and the one write in the public tier: the caller is reading
// their mail, not signed in. Always resolves 200 whatever the token was.
unsubscribeTeam: (token) =>
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
// ----- Events (EVENTS.md § API surface, Phase 14a) -----
//
// The anonymous surface. `from`/`to` are optional — the server defaults to now
// through a month out, so the calendar's first render need not compute a window
// before it can ask for anything.
publicEvents: ({ from, to, seriesId } = {}) => {
const qs = new URLSearchParams()
if (from) qs.set('from', from)
if (to) qs.set('to', to)
if (seriesId) qs.set('seriesId', String(seriesId))
return req(`/public/events${withQs(qs.toString())}`)
},
// `run` is what an announcement's link carries, so a mail about last Friday's
// occurrence opens last Friday's results rather than next Friday's.
publicEvent: (slug, run = null) =>
req(`/public/events/${encodeURIComponent(slug)}${run ? `?run=${encodeURIComponent(run)}` : ''}`),
publicEventSeries: (slug) => req(`/public/events/series/${encodeURIComponent(slug)}`),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -282,6 +137,110 @@ 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${withQs(s)}`)
},
economy: (limit) => {
const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/economy${withQs(q)}`)
},
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) => {
const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
},
presence: () => req('/public/shard/presence'),
houses: () => req('/public/shard/houses'),
// Protocol 3.0: the shard's published ruleset. Resolves to null when the
// shard has never published one — a real answer, not an error.
ruleset: () => req('/public/shard/ruleset'),
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
// `board` 404s for a system the shard has never published.
points: () => req('/public/shard/points'),
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
// the page debounces its search box rather than firing per keystroke.
market: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
if (opts.map) qs.set('map', opts.map)
if (opts.region) qs.set('region', opts.region)
if (opts.sort) qs.set('sort', opts.sort)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market${withQs(qs.toString())}`)
},
marketMeta: () => req('/public/shard/market/meta'),
marketVendor: (serial, opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
},
// Which shard surfaces this caller may reach, plus the audience rung they
// resolved to. Drives nav so we never render a link that would 403.
features: () => req('/public/shard/features'),
},
// ----- spawn atlas (Protocol 3.0 Part C) -----
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
// not under /shard, because nothing here depends on the sidecar and the pages
// stay populated while the shard is offline.
atlas: {
creatures: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.facet) qs.set('facet', opts.facet)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
},
creature: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.points) qs.set('points', opts.points)
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
},
regions: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/regions${withQs(qs.toString())}`)
},
landmarks: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
},
// The CONFIGURED altar roster, not the live board — see shard.champs() for
// "which spawn is on level 3 right now".
champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
meta: () => req('/public/atlas/meta'),
},
// 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'),
@@ -361,12 +320,6 @@ export const api = {
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// Accounts whose address was cleared when addresses became unique (Phase 1b).
// They can still sign in but can receive no mail until they set a new one, so
// they are the list an operator has to work through.
emailDedupeReport: () => req('/admin/users/email-dedupe-report'),
acknowledgeEmailDedupeReport: () =>
req('/admin/users/email-dedupe-report/acknowledge', { method: 'POST' }),
// A user's trusted devices + MFA reset (admin only).
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
revokeUserTrustedDevice: (id, deviceId) =>
@@ -379,266 +332,24 @@ export const api = {
createInvite: (email, role, sendEmail = true) =>
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
// Installed modules (MODULE_SYSTEM.md §2.7.2). `uninstallModule`'s purge flag
// is a query parameter rather than a body because it hangs off a DELETE, and
// it is spelled out at the call site rather than defaulted, so the
// destructive branch is never the one you get by forgetting an argument.
listModules: () => req('/admin/modules'),
installModule: (url) => req('/admin/modules', { method: 'POST', body: { url } }),
enableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
disableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
uninstallModule: (id, { purge } = {}) =>
req(`/admin/modules/${encodeURIComponent(id)}${purge ? '?purge=true' : ''}`, { method: 'DELETE' }),
purgeModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/purge`, { method: 'POST' }),
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
// Engagement (docs/website/ENGAGEMENT.md Phase 4b). The first three are the
// catalog — triggers, audiences and channels, all served from the registries
// rather than from tables, so an installed module's declarations appear here
// without a client release.
//
// `setEngagementRuleEnabled` is its own call rather than a `saveEngagementRule`
// with one field, because the route is its own route: turning a rule off must
// work on a rule the registries would now refuse, which is exactly the rule an
// operator most wants stopped.
//
// `previewEngagementReach` answers with a COUNT and never a list of people.
engagementTriggers: () => req('/admin/engagement/triggers'),
engagementAudiences: () => req('/admin/engagement/audiences'),
engagementChannels: () => req('/admin/engagement/channels'),
listEngagementRules: () => req('/admin/engagement/rules'),
createEngagementRule: (body) => req('/admin/engagement/rules', { method: 'POST', body }),
updateEngagementRule: (id, body) => req(`/admin/engagement/rules/${id}`, { method: 'PUT', body }),
setEngagementRuleEnabled: (id, enabled) =>
req(`/admin/engagement/rules/${id}/enabled`, { method: 'PATCH', body: { enabled } }),
deleteEngagementRule: (id) => req(`/admin/engagement/rules/${id}`, { method: 'DELETE' }),
listEngagementSegments: () => req('/admin/engagement/segments'),
createEngagementSegment: (body) => req('/admin/engagement/segments', { method: 'POST', body }),
updateEngagementSegment: (id, body) => req(`/admin/engagement/segments/${id}`, { method: 'PUT', body }),
deleteEngagementSegment: (id) => req(`/admin/engagement/segments/${id}`, { method: 'DELETE' }),
previewEngagementReach: ({ audience, audienceSegmentId, triggerId } = {}) => {
const qs = new URLSearchParams()
if (audienceSegmentId) qs.set('audienceSegmentId', String(audienceSegmentId))
else if (audience) qs.set('audience', audience)
if (triggerId) qs.set('triggerId', triggerId)
return req(`/admin/engagement/audience-preview${withQs(qs.toString())}`)
},
// Templates and the send log (engagement Phase 5b). `previewEngagementTemplate`
// and `testSendEngagementTemplate` are POSTs that write nothing: both act on
// the draft in the request, so the editor can show and send what is on screen
// rather than what was last saved.
listEngagementTemplates: () => req('/admin/engagement/templates'),
getEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`),
updateEngagementTemplate: (id, body) =>
req(`/admin/engagement/templates/${id}`, { method: 'PUT', body }),
duplicateEngagementTemplate: (id, body) =>
req(`/admin/engagement/templates/${id}/duplicate`, { method: 'POST', body }),
deleteEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`, { method: 'DELETE' }),
previewEngagementTemplate: (id, body) =>
req(`/admin/engagement/templates/${id}/preview`, { method: 'POST', body }),
testSendEngagementTemplate: (id, body) =>
req(`/admin/engagement/templates/${id}/test-send`, { method: 'POST', body }),
listEngagementSends: ({ limit, offset, triggerId, ruleId, userId, status } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (offset) qs.set('offset', String(offset))
if (triggerId) qs.set('triggerId', triggerId)
if (ruleId) qs.set('ruleId', String(ruleId))
if (userId) qs.set('userId', String(userId))
if (status) qs.set('status', status)
return req(`/admin/engagement/sends${withQs(qs.toString())}`)
},
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
// of a DELETE rather than in the path, and that is not style: a path
// parameter lands in the access log, the browser history and every proxy in
// front of the deployment, and this one is a real person's address.
//
// **Phase 14 added the second form, and it is the one the row uses.** The
// list now returns each row's `address_hash`, so the Lift button on a row
// needs no address at all — the operator is looking at a mask and has never
// been told the address. `unsuppressAddress` stays for the address the
// operator types, which is the only way to reach a row that is not on the
// page in front of them.
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (offset) qs.set('offset', String(offset))
if (reason) qs.set('reason', reason)
if (channel) qs.set('channel', channel)
if (search) qs.set('search', search)
return req(`/admin/engagement/suppressions${withQs(qs.toString())}`)
},
suppressAddress: (address, detail) =>
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
unsuppressAddress: (address, channel) =>
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
unsuppressByHash: (hash, channel) => {
const qs = new URLSearchParams()
if (channel) qs.set('channel', channel)
return req(`/admin/engagement/suppressions/by-hash/${hash}${withQs(qs.toString())}`, {
method: 'DELETE',
})
},
// Retention (Phase 14). Three horizons, one screen; `engagement_suppressions`
// is not among them because a suppression does not expire.
getEngagementRetention: () => req('/admin/engagement/retention'),
setEngagementRetention: (body) =>
req('/admin/engagement/retention', { method: 'PUT', body }),
// Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring
// is admin+editor, publish and start are admin ONLY, and the six live
// controls are admin+moderator — the one gate in this feature wider than
// admin, because stopping a run at 2am is incident response and starting
// one is not (§N2). The buttons follow the same split, and the server
// re-checks every one of them.
listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`),
getEvent: (id) => req(`/admin/events/${id}`),
createEvent: (body) => req('/admin/events', { method: 'POST', body }),
updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }),
publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }),
archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }),
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
eventCatalog: () => req('/admin/events/catalog'),
// Phase 7. The values behind a param's `source` — resolved by the module that
// registered the source, on a request of its own rather than inside the
// catalog, because a source can be slow or down and must not take the whole
// editor with it. A refusal comes back 200 with `ok: false`, so this never
// throws for the case the screen is meant to render: the field degrades to
// free text with the reason beside it.
// Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is
// ignored, never refused, by a source that does not declare itself
// searchable — so passing it is always safe and the field decides whether
// it is a typeahead by reading `searchable` off the answer.
eventOptions: (sourceId, q) => {
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
},
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
// who wrote the definition is who should be able to price it against the caps
// before asking an admin to publish it. A report with findings comes back 200
// — the request succeeded, the plan has problems.
verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }),
// Phase 13's live cap meter, and NOT a lighter dry run — it dispatches
// nothing, so it knows nothing a module knows. It takes the spec in the
// body rather than an id because the plan it prices is the one in the
// author's hands, which is unsaved between keystrokes, and it records
// nothing, which is what makes it safe to call on a debounce.
priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }),
// The switchboard, admin only in BOTH directions: reading which actions a
// deployment permits is as much configuration as writing it (§K). One action
// per write rather than the whole board, so an action that appeared between
// the read and the write cannot be overwritten with a default.
eventActions: () => req('/admin/events/actions'),
saveEventAction: (body) => req('/admin/events/actions', { method: 'PUT', body }),
eventSeries: () => req('/admin/events/series'),
// Series writes are admin+editor rather than admin: naming an arc is
// authoring, and §N2's narrow gate is about committing the deployment to a
// run. The delete is a real delete and answers with how many definitions it
// detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed.
createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }),
updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }),
deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }),
// The calendar. `from`/`to` are UTC instants the caller computes from the
// month it is showing, in the READER's zone — the server never guesses it.
// A `status` or `scope` filter suppresses projections, which is why the
// month view sends neither.
eventCalendar: ({ from, to, status, scope, seriesId } = {}) => {
const qs = new URLSearchParams({ from, to })
if (status) qs.set('status', status)
if (scope) qs.set('scope', scope)
if (seriesId) qs.set('seriesId', String(seriesId))
return req(`/admin/events/calendar?${qs.toString()}`)
},
startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }),
listEventRuns: ({ definitionId, status, limit } = {}) => {
const qs = new URLSearchParams()
if (definitionId) qs.set('definitionId', String(definitionId))
if (status) qs.set('status', status)
if (limit) qs.set('limit', String(limit))
const suffix = qs.toString()
return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`)
},
getEventRun: (runId) => req(`/admin/events/runs/${runId}`),
getEventRunLog: (runId, limit) =>
req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`),
pauseEventRun: (runId, reason) =>
req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }),
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
// `cleanup` defaults to true server-side and has to be asked out of: EVENTS.md
// §L makes cancelling WITHOUT cleanup the separate, admin-only, logged action,
// so an absent flag means "give back what this run took".
cancelEventRun: (runId, reason, cleanup = true) =>
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason, cleanup } }),
cleanupEventRun: (runId) => req(`/admin/events/runs/${runId}/cleanup`, { method: 'POST' }),
advanceEventRun: (runId, reason) =>
req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }),
confirmEventStep: (runId, stepId, note) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
skipEventStep: (runId, stepId, reason) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }),
retryEventStep: (runId, stepId) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }),
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and
// setTeamDisplayName file a request and the response says `pending: true`.
// The caller does not choose — the server decides from the live role — so
// there is deliberately no "asRequest" argument to get wrong.
listTeams: () => req('/admin/teams'),
getTeam: (id) => req(`/admin/teams/${id}`),
resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
teamGrants: (id) => req(`/admin/teams/${id}/grants`),
hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
setTeamDisplayName: (id, displayName, reason) =>
req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
setTeamLeaderOverride: (id, body) =>
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
clearTeamLeaderOverride: (id, memberKey) =>
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
teamForumSettings: () => req('/admin/teams/forum/settings'),
// The notification bridge (TEAMS.md §7.2). Admin-only server-side, so a
// moderator's admin panel never renders the panel that calls these.
teamIntegrations: () => req('/admin/teams/integrations'),
saveTeamIntegration: (body) => req('/admin/teams/integrations', { method: 'PUT', body }),
deleteTeamIntegration: (teamId) =>
req(`/admin/teams/integrations/${teamId === null ? 'default' : teamId}`, { method: 'DELETE' }),
// Voice channels (TEAMS.md §7.3). Admin-only server-side, like the bridge.
teamVoice: () => req('/admin/teams/voice'),
saveTeamVoice: (body) => req('/admin/teams/voice', { method: 'PUT', body }),
teamVoicePass: () => req('/admin/teams/voice/sync', { method: 'POST' }),
removeTeamVoice: (teamId) => req(`/admin/teams/voice/${teamId}`, { method: 'DELETE' }),
teamForumUploads: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.deleted) qs.set('deleted', '1')
return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
},
teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
teamReviewQueue: () => req('/admin/teams/review'),
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
decideTeamRequest: (id, status, note) =>
req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
// account) so the shared GameAccounts component works unchanged.
userShard: (id) => ({
accounts: () => req(`/admin/users/${id}/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/users/${id}/shard/sales`),
houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`),
standing: () => req(`/admin/users/${id}/shard/standing`),
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
}),
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
// The content-report queue (TEAMS.md §5.6). Under moderation rather than
// under Teams because a staffer working a queue should have one place to
// work, and a report about a forum post is the same job as a report about
// anything else — which is also why `targetType` is open-ended.
contentReports: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.status) qs.set('status', opts.status)
if (opts.teamId) qs.set('teamId', String(opts.teamId))
return req(`/admin/moderation/reports${withQs(qs.toString())}`)
},
handleContentReport: (id, body) =>
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
modRecent: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
@@ -698,6 +409,29 @@ export const api = {
req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),
// ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'),
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
// ----- linked SSO identities (self-service) -----
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'),
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
createAccount: (account, password) =>
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
},
// ----- auth providers / SSO config (admin only) -----
listAuthProviders: () => req('/admin/auth/providers'),
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
@@ -708,36 +442,86 @@ export const api = {
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
// ----- Email delivery (admin only) -----
// The connect-flow call went with Gmail OAuth2 (ENGAGEMENT.md §1.2a); the
// config response now carries the transport catalog the form renders from.
// ----- 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' }),
// Per-feature shard visibility: who may see which shard surface, and which
// sensitive fields within it. Admin only — it decides what ANONYMOUS
// visitors get. acct/webId are admin-only always and the API rejects any
// attempt to configure them.
getShardVisibility: () => req('/admin/shard/visibility'),
saveShardVisibility: (features) =>
req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
// ----- spawn atlas operation (admin only) -----
// The atlas re-derives itself from the ServUO tree on every boot; these are
// for applying a map change without a restart, and for the approve/reject
// decision on a refresh that would remove a facet.
atlas: {
status: () => req('/admin/shard/atlas'),
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
},
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
// `actor` is stamped server-side from the session — never sent from here.
shardOps: {
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
pages: () => req('/admin/shard/pages'),
respondPage: (id, data) =>
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
},
// ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
emailConnectUrl: () => req('/admin/email/connect/start'),
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
},
// ----- player self-service (role: 'player') -----
// Account security is NOT here — it is role-agnostic and lives at the root of
// this object, on /auth/me/account. What remains is genuinely player-scoped.
// Mirrors the admin account methods but self-scoped under /player. The change
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
player: {
getAccount: () => req('/player/account'),
changeUsername: (username) =>
req('/player/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
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'),
houses: () => req('/player/shard/houses'), // the caller's own houses
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
},
// ----- moderation appeals (self-service) -----
getMyAppeals: () => req('/player/appeals'),
getEligibleAppeals: () => req('/player/appeals/eligible'),
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
// ----- event participation (Phase 14a) -----
//
// Self-scoped on the session and nothing else — there is no id to pass.
// `before` is a keyset cursor (the last entry's `id`), not an offset: the
// list gains a row every time the reader attends something.
eventHistory: ({ limit, before } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (before) qs.set('before', String(before))
return req(`/player/events/history${withQs(qs.toString())}`)
},
},
}

View File

@@ -0,0 +1,293 @@
// 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).
//
// `moderation` opts in the in-game kick/ban controls for the character's account;
// they self-gate to staff (ShardAccountActions), so passing it from a page a
// player can reach is safe.
import ShardAccountActions from './ShardAccountActions.jsx'
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
// What to call an equipped item.
//
// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
// to show nothing but the layer and `id 12345`. The server now resolves the
// cliloc against its own table and attaches `clilocName` (see
// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
// and the layer fallback below is exactly what the sheet did before.
//
// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
// should not be relabelled "hatchet" — and the server applies the same
// precedence, so this only re-states it for a profile that arrived with both.
const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
// literal string.
//
// `rewardResolved` is the server's parallel array with the numeric entries turned
// into words (null where the cliloc table had nothing, or is not configured at
// all). Prefer it, and keep the literal-only path as the fallback for a profile
// served before the cliloc table existed — a numeric entry with no resolution is
// still skipped rather than shown as a raw number.
function displayTitles(titles) {
if (!titles) return []
const out = []
if (titles.fameKarma) out.push(titles.fameKarma)
if (titles.skill) out.push(titles.skill)
const raw = Array.isArray(titles.reward) ? titles.reward : []
const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
const sel = typeof titles.selected === 'number' ? titles.selected : -1
// Prefer the selected reward title; fall back to the first one that resolved.
// The `??` matters: a selected title whose cliloc did not resolve must fall
// through to the fallback rather than suppress the chip entirely.
const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
if (candidate) out.push(String(candidate))
return [...new Set(out.filter(Boolean))]
}
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
// the character actually holds a score in. Systems at zero are omitted by the
// shard, so an empty list means "this character has earned nothing anywhere",
// which is a normal state for a new character and renders as nothing at all.
//
// `nameString` may be null when the system's name is a cliloc; fall back to
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
// "unranked" are different, so the chip only appears when it was actually sent.
const humanisePoints = (key) =>
String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, (c) => c.toUpperCase())
function PointsRow({ entry }) {
const label = entry.nameString || humanisePoints(entry.system)
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
{label}
{Number.isFinite(entry.rank) && (
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
)}
</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
{(entry.points ?? 0).toLocaleString()}
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
</span>
</div>
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
be a fraction of, and a full-width bar would imply completion. */}
{max > 0 && (
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
)}
</div>
)
}
function TitleChip({ children, tone = 'var(--muted)' }) {
return (
<span
className="sans"
style={{
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
}}
>
{children}
</span>
)
}
function StatTile({ value, label }) {
return (
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
</div>
)
}
function Vital({ label, cur, max }) {
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
return (
<div className="panel" style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
</div>
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
}
export default function CharacterSheet({ char, moderation = false }) {
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 || []
// Best standing first, so the character's strongest loyalty leads. Guarded for
// an older shard plugin that sends no `points` block at all.
const points = (Array.isArray(char.points) ? char.points : [])
.filter((p) => p && (p.points || 0) > 0)
.sort((a, b) => (b.points || 0) - (a.points || 0))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
{/* Identity */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
border: '1px solid var(--line)', fontSize: '0.74rem',
color: char.online ? '#7fd0a4' : 'var(--muted)',
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
{char.online ? 'Online' : 'Offline'}
</span>
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div>
{/* Titles + standing (guild led / governorship) — all optional */}
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
{char.governorOf && char.governorOf.map((city) => (
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
))}
{char.guild && (
<TitleChip tone="var(--accent)">
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
</TitleChip>
)}
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
</div>
)}
{/* Staff moderation for this character's account (self-gates to staff). */}
{moderation && char.acct && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
<ShardAccountActions account={char.acct} />
</div>
)}
{/* Core stats */}
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
<div className="grid-3" style={{ gap: 12 }}>
<StatTile value={stats.str ?? '—'} label="Strength" />
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
<StatTile value={stats.int ?? '—'} label="Intelligence" />
</div>
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
</div>
</section>
{/* Resistances */}
{Object.keys(resist).length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
</div>
))}
</div>
</section>
)}
{/* Skills */}
{skills.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{skills.map((s) => {
const cap = s.cap || 100
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
return (
<div key={s.n}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
})}
</div>
</section>
)}
{/* Loyalty & points — one entry per system this character has scored in */}
{points.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>
Loyalty &amp; points <span className="dim">({points.length})</span>
</div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{points.map((p) => (
<PointsRow key={p.system} entry={p} />
))}
</div>
</section>
)}
{/* Equipment */}
{equipment.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{equipment.map((it) => {
const label = itemName(it)
const layer = it.layer || 'Item'
// The layer only earns its own line once the headline is a real
// name; when it IS the headline, repeating it is just noise.
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
return (
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
</div>
{it.mods && Object.keys(it.mods).length > 0 && (
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
{Object.entries(it.mods).map(([k, v]) => (
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
))}
</div>
)}
</div>
)
})}
</div>
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react'
// A small stat-tile row for a "My Characters" page: total characters, how many
// are online right now, and how many game accounts are linked. `scope` is the
// shard api object (admin or player self-service). Renders nothing until an
// account is linked, so the empty/link-prompt state below it stands alone.
//
// It fetches the same rosters GameAccounts loads; for a personal page that's at
// most a couple of extra live round-trips, and keeps this presentational bit
// decoupled from GameAccounts' per-account roster loading.
function Tile({ value, label }) {
return (
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
{label}
</div>
</div>
)
}
// Fold the settled roster results into totals. `complete` is false when any
// account's roster failed (a partial result — shown as a dash rather than a
// misleadingly low count).
function summarizeRosters(rosters) {
let chars = 0
let online = 0
let complete = true
for (const r of rosters) {
if (r.status !== 'fulfilled') {
complete = false
continue
}
const cs = r.value.chars || []
chars += cs.length
online += cs.filter((c) => c.online).length
}
return { chars, online, complete }
}
export default function CharacterStats({ scope }) {
const [stats, setStats] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const accounts = await scope.accounts()
const linked = accounts.length
if (linked === 0) {
if (!cancelled) setStats({ linked: 0 })
return
}
// Roster is a live round-trip and can be unavailable (503); tolerate a
// partial result so a restarting shard doesn't blank the whole row.
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
} catch {
if (!cancelled) setStats({ error: true })
}
})()
return () => { cancelled = true }
}, [scope])
// Hidden until we know an account is linked (or while first loading).
if (!stats || stats.error || stats.linked === 0) return null
// Counts depend on live rosters; show a dash if none came back.
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
return (
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
<Tile value={count(stats.chars)} label="Characters" />
<Tile value={count(stats.online)} label="Online now" />
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
</section>
)
}

View File

@@ -0,0 +1,69 @@
import { useState } from 'react'
// Reusable "create a game account" form (its own username + password — the game
// client credentials, distinct from the website login). Calls `submit(account,
// password)` which should POST /player/shard/account; on success calls onCreated.
// Used by the player portal (self-serve) and the invite-accept page alike.
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
const [account, setAccount] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function onSubmit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
return setError('Account name must be 330 letters, numbers, . _ or -.')
}
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await submit(account, password)
setMsg(`Game account “${account}” created and linked.`)
setAccount(''); setPassword('')
if (onCreated) await onCreated()
} catch (err) {
if (err.status === 409) setError('That account name is already taken.')
else if (err.status === 429) setError('The account limit for your network has been reached.')
else if (err.status === 403) setError('Game-account signup is not available right now.')
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
else setError(err.message || 'Could not create the account right now.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit}>
{!compact && (
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose the username and password youll type into the game client. These are your
<strong style={{ color: 'var(--head)' }}> game</strong> credentials separate from your website login.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">Game account name</span>
<input
type="text" autoComplete="off" value={account}
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
/>
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Game password</span>
<input
type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} className="input"
/>
</label>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : 'Create game account'}
</button>
</form>
)
}

View File

@@ -0,0 +1,231 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from './PageState.jsx'
import ShardAccountActions from './ShardAccountActions.jsx'
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
import { api } from '../api/client.js'
// 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. `readOnly` drops the
// link forms and self-voice copy for the admin case where staff view *another*
// user's accounts (no `scope.link`) at /admin/users/:id.
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 (
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
<label style={{ display: 'block' }}>
{!compact && <span className="field-label">Link code</span>}
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="input"
autoComplete="off"
placeholder="AB12CD"
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
/>
</label>
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
{busy ? 'Linking…' : 'Link account'}
</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>}
</form>
)
}
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 (
<div>
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting try again shortly.</p>
<button className="pill" onClick={load}>Retry</button>
</div>
)
}
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading</p>
const chars = roster.chars || []
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
return (
<div className="grid-2" style={{ gap: 12 }}>
{chars.map((c) => (
<Link
key={c.serial}
to={charTo(c.serial)}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
>
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
{(c.name || '?').charAt(0)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
</div>
<span className="sans dim" style={{ fontSize: '1.1rem' }}></span>
</Link>
))}
</div>
)
}
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
// then calls onUnlink(account) and reloads. Errors surface inline.
function UnlinkButton({ account, onUnlink }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function go() {
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
setBusy(true); setError('')
try {
await onUnlink(account)
} catch (err) {
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
setError(byStatus[err.status] || err.message || 'Could not unlink.')
setBusy(false)
}
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
</span>
)
}
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
// Whether the site currently offers game-account creation (public flag). Only
// relevant for the self-service (non-readOnly) view with a createAccount scope.
const [signupOk, setSignupOk] = useState(false)
const load = useCallback(async () => {
setError('')
try {
setAccounts(await scope.accounts())
} catch {
setError(readOnly ? 'Could not load this users game accounts.' : 'Could not load your game accounts.')
}
}, [scope, readOnly])
useEffect(() => { load() }, [load])
useEffect(() => {
if (readOnly || !scope.createAccount) return
let active = true
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false }
}, [readOnly, scope])
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
if (error) return <ErrorState message={error} />
if (!accounts) return <Loading />
// No linked accounts. In read-only (admin viewing another user) this is just an
// empty state; otherwise it's the link-your-account prompt.
if (accounts.length === 0) {
if (readOnly) {
return (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
This user has not linked a game account.
</p>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
one-time code, then enter it below to see your characters, stats, skills and vendors here.
</p>
<LinkForm scope={scope} onLinked={load} />
</div>
{canCreate && (
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
</div>
)}
</div>
)
}
// Linked — characters grouped by account.
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
{accounts.map((a) => (
<section key={a.account}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{a.account}
</div>
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
</div>
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
</section>
))}
{!readOnly && (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm scope={scope} onLinked={load} compact />
{canCreate && (
<div style={{ marginTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
</div>
)}
</section>
)}
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import Maintenance from '../routes/public/Maintenance.jsx'
// Wraps the public site. When the site is in maintenance, visitors see the
// Wraps the public site. When the shard is in maintenance, visitors see the
// coming-soon page; a logged-in admin sees the real site (live preview).
export default function MaintenanceGate({ children }) {
const { mode, loading } = useSite()

View File

@@ -1,353 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext.jsx'
import { api } from '../api/client.js'
import { inboxPath } from '../lib/notificationPaths.js'
// The in-app inbox's header surface (ENGAGEMENT.md Phase 7): a bell with an
// unread badge, and a panel with the most recent items.
//
// **The badge is polled, not pushed**, and the reason is that there is nothing
// to push over. The site's two SSE streams are the shard's; neither is
// per-user, and adding a third authenticated stream to carry an integer would
// mean one open connection per signed-in tab for the rest of the deployment's
// life. A minute-granular badge on a page somebody is already looking at is the
// same answer for a fraction of that. The poll pauses while the tab is hidden —
// a background tab has nobody to show a badge to — and refreshes the moment it
// comes back, which is also the moment it would be most wrong.
//
// **The panel shows a handful and links out.** Paging belongs on the page; a
// dropdown that scrolls is a list in the wrong place.
//
// Dismissal follows `NavDropdown`'s contract exactly — Escape closes and
// returns focus, an outside `mousedown` closes, navigating closes — because
// this sits beside it in the same header and two menus that dismiss differently
// is a bug nobody files.
const POLL_MS = 60_000
const PANEL_ITEMS = 6
function BellIcon({ size = 17 }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 01-3.4 0" />
</svg>
)
}
// "3m", "4h", "6d" — a relative stamp, because the only question a reader has
// about an inbox item's time is how fresh it is.
function ago(iso) {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'now'
if (secs < 3600) return `${Math.floor(secs / 60)}m`
if (secs < 86400) return `${Math.floor(secs / 3600)}h`
return `${Math.floor(secs / 86400)}d`
}
export default function NotificationBell() {
const { user } = useAuth()
const [unread, setUnread] = useState(0)
const [items, setItems] = useState([])
const [open, setOpen] = useState(false)
const [error, setError] = useState('')
const wrapRef = useRef(null)
const triggerRef = useRef(null)
const location = useLocation()
const navigate = useNavigate()
// Every read here swallows its failure. A count that could not be fetched is
// a bell with no badge, which is what a bell with nothing to report looks
// like anyway — the alternative is an error banner in the site header for a
// number nobody asked for.
const refreshCount = useCallback(async () => {
if (!user) return
try {
const res = await api.notificationsUnreadCount()
setUnread(res.unread || 0)
} catch {
/* leave the badge as it was */
}
}, [user])
useEffect(() => {
if (!user) return undefined
refreshCount()
const timer = setInterval(() => {
if (document.visibilityState === 'visible') refreshCount()
}, POLL_MS)
const onVisible = () => {
if (document.visibilityState === 'visible') refreshCount()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(timer)
document.removeEventListener('visibilitychange', onVisible)
}
}, [user, refreshCount])
// The panel's items are fetched when it opens, never kept warm: a list nobody
// has asked to see is a request per minute for content nobody is reading.
const load = useCallback(async () => {
setError('')
try {
const res = await api.notifications({ limit: PANEL_ITEMS })
setItems(res.items || [])
setUnread(res.unread || 0)
} catch (err) {
setError(err.message || 'Could not load notifications')
}
}, [])
useEffect(() => setOpen(false), [location.pathname])
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key !== 'Escape') return
setOpen(false)
triggerRef.current?.focus()
}
const onOutside = (e) => {
if (!wrapRef.current?.contains(e.target)) setOpen(false)
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onOutside)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onOutside)
}
}, [open])
if (!user) return null
const toggle = () => {
const next = !open
setOpen(next)
if (next) load()
}
// Opening an item marks it read and then goes where it points. The mark is
// awaited rather than fired off, so the badge the next screen renders is the
// one this click produced; a failed mark still navigates, because the item's
// link is the thing the user asked for.
const openItem = async (item) => {
setOpen(false)
if (!item.read) {
try {
const res = await api.markNotificationRead(item.id)
setUnread(res.unread ?? Math.max(0, unread - 1))
} catch {
/* the link still works */
}
}
navigate(item.url || inboxPath(user))
}
const markAll = async () => {
try {
await api.markAllNotificationsRead()
setUnread(0)
setItems((list) => list.map((i) => ({ ...i, read: true })))
} catch (err) {
setError(err.message || 'Could not mark them read')
}
}
return (
<div ref={wrapRef} style={{ position: 'relative' }}>
<button
ref={triggerRef}
type="button"
className="pill"
aria-haspopup="true"
aria-expanded={open}
// The count is in the label, not only in the badge: a screen reader gets
// "Notifications, 3 unread" rather than "Notifications" and a number it
// has no way to relate to it.
aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'}
onClick={toggle}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
position: 'relative',
...(open ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}),
}}
>
<BellIcon />
{unread > 0 && (
<span
aria-hidden="true"
className="sans"
style={{
minWidth: 17,
height: 17,
padding: '0 4px',
borderRadius: 9,
background: 'var(--accent)',
color: 'var(--bg-deep)',
fontSize: '0.68rem',
fontWeight: 700,
lineHeight: '17px',
textAlign: 'center',
}}
>
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div
role="menu"
aria-label="Notifications"
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
right: 0,
width: 320,
maxWidth: 'calc(100vw - 24px)',
padding: 6,
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line)',
background: 'var(--panel-flat)',
boxShadow: 'var(--shadow-card)',
zIndex: 40,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
padding: '4px 8px 8px',
}}
>
<strong className="sans" style={{ fontSize: '0.82rem', color: 'var(--head)' }}>
Notifications
</strong>
{unread > 0 && (
<button
type="button"
onClick={markAll}
className="sans"
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--accent)',
fontSize: '0.78rem',
}}
>
Mark all read
</button>
)}
</div>
{error && (
<p className="sans" style={{ margin: '0 8px 8px', fontSize: '0.8rem', color: '#d98b84' }}>
{error}
</p>
)}
{!error && items.length === 0 && (
<p className="sans dim" style={{ margin: '0 8px 10px', fontSize: '0.82rem' }}>
Nothing here yet.
</p>
)}
{items.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
onClick={() => openItem(item)}
className="sans"
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '8px 10px',
borderRadius: 'var(--radius-input)',
border: 'none',
cursor: 'pointer',
background: item.read ? 'transparent' : 'var(--panel)',
}}
>
<span
style={{
display: 'block',
fontSize: '0.85rem',
color: item.read ? 'var(--muted)' : 'var(--head)',
fontWeight: item.read ? 400 : 600,
}}
>
{item.title}
</span>
{item.body && (
<span
className="dim"
style={{
fontSize: '0.78rem',
marginTop: 2,
// The body is stored and rendered as TEXT, never as markup —
// `white-space: pre-line` is what keeps the template's own
// line breaks without ever interpreting anything.
whiteSpace: 'pre-line',
// Two lines, then an ellipsis. `-webkit-box` is the only
// clamp with real support; it is also why there is no second
// `display: block` above it.
display: '-webkit-box',
overflow: 'hidden',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.body}
</span>
)}
<span className="dim" style={{ display: 'block', fontSize: '0.72rem', marginTop: 3 }}>
{ago(item.createdAt)}
</span>
</button>
))}
<Link
to={inboxPath(user)}
role="menuitem"
onClick={() => setOpen(false)}
className="sans"
style={{
display: 'block',
marginTop: 4,
padding: '8px 10px',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.8rem',
color: 'var(--accent)',
textDecoration: 'none',
}}
>
See all notifications
</Link>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useMemo } from 'react'
import { useAsync } from '../lib/useAsync.js'
import { useShardFeed } from '../lib/useShardFeed.js'
import { bucketize } from '../data/regionBuckets.js'
import { api } from '../api/client.js'
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
// then keeps the total + region breakdown current from the presence.online SSE
// kind. The raw byRegion map is rolled up into display buckets (see
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
const PRESENCE_KINDS = new Set(['presence.online'])
export default function PlayersOnline() {
const { loading, error, data } = useAsync(() => api.shard.presence())
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
// The freshest snapshot wins: the newest buffered presence.online event, else
// the initial fetch.
const snapshot = events[0] || data
const { total, rows } = useMemo(() => {
const count = Number(snapshot?.count) || 0
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
return { total: count, rows: bucketRows }
}, [snapshot])
return (
<section className="panel" style={{ padding: 20 }}>
<div
className="sans"
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
>
<span
style={{
color: 'var(--accent)',
fontSize: '0.7rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
Players online
</span>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
{loading ? '—' : total}
</span>
</div>
{error && (
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
Population is unavailable right now.
</p>
)}
{!loading && !error && (
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
</p>
) : (
rows.map((r) => (
<div
key={r.id}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
fontSize: '0.9rem',
color: 'var(--ink)',
}}
>
<span>{r.label}</span>
{/* tabular figures keep the right-aligned counts in a clean column */}
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
</div>
))
)}
</div>
)}
</section>
)
}

View File

@@ -1,36 +1,12 @@
import SiteHeader from './SiteHeader.jsx'
import SiteFooter from './SiteFooter.jsx'
import { shellClass } from '../lib/pageShell.js'
// Standard page chrome for the public site + wiki.
//
// ── `shell` — added in MODULE_API_VERSION 1.5.0 ────────────────────────────
//
// This component supplies the chrome and NOT the body: every core public page
// wraps its own content in `<div className="shell-… page-body">`, which is what
// centres it in a max-width column, gives it its top and bottom padding, and —
// through `page-body { flex: 1 }` — pushes the footer to the bottom of the
// viewport. Nine of nine core pages do it, so the omission has never shown.
//
// A module page cannot: it is handed `PublicLayout` through the UI kit
// (MODULE_API.md §3.4) and has no way to learn about two class names that appear
// in no contract. The Integration Kit's acceptance run built a module exactly as
// the kit teaches and it rendered full-bleed at x=0 with the footer riding up
// under the content — the precise failure §3.4 says the kit exists to prevent
// ("a module page that does not look like the site it is installed in").
//
// So the wrapper moves behind the component a module already has. `shell` is
// OPT-IN and omitting it is exactly today's behaviour, which is why core's own
// nine pages are untouched by this change — they keep their own wrapper, and a
// page wanting an unusual body still writes its own. The width mapping and its
// fallback are in lib/pageShell.js, where the DOM-less test runner can reach them.
export default function PublicLayout({ section = 'website', header = true, shell, children }) {
const bodyClass = shellClass(shell)
export default function PublicLayout({ section = 'website', header = true, children }) {
return (
<div className="page">
{header && <SiteHeader section={section} />}
{bodyClass ? <div className={bodyClass}>{children}</div> : children}
{children}
<SiteFooter />
</div>
)

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext.jsx'
import { api } from '../api/client.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>
)
}

View File

@@ -1,14 +1,5 @@
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()
@@ -40,19 +31,15 @@ export default function SiteFooter() {
</span>
</div>
<div className="site-footer-info">
<span>{siteTitle} is an independent, privately-run game server.</span>
<span>{siteTitle} is an independent private shard project.</span>
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{contactEmail}
</a>
{/* 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. */}
<Slot name={FOOTER_SLOT} linkStyle={LINK_STYLE} wrap={(link) => <>&nbsp;·&nbsp;{link}</>} />
&nbsp;·&nbsp;
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Shard Status
</Link>
&nbsp;·&nbsp;
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
Admin

View File

@@ -4,36 +4,38 @@ import MoonDot from './MoonDot.jsx'
import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
import NavDropdown from './NavDropdown.jsx'
import NotificationBell from './NotificationBell.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { navFor } from '../modules/registry.js'
import { parseJsonSetting } from '../lib/settingsJson.js'
import { withModuleNav } from '../modules/nav.js'
import { useFeatureGate } from '../modules/features.jsx'
// 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).
//
// A row may carry a `feature`, naming a surface an installed module can disable
// or gate to a higher audience; it is hidden when this viewer cannot reach it,
// so we never render a link that would 403. No CORE row carries one today — the
// nine that did were UO and left with the client half in slice 3 — but the gate
// is not dead code: a module's rows join this list and bring their own flags,
// resolved by the module that registered them (modules/featureGate.js).
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
// viewer can't reach them, so we never render a link that would 403. The gate
// itself is server-side; this is only about not advertising a dead end.
//
// Exported because Admin -> Navigation edits this list. It stays declared here,
// with this component as its owner: the editor may only relabel, reorder and
// hide what it finds, and `to`/`feature` are never its to change (§7). An
// installed module's rows join it in `withModuleNav` below — before the override
// merge, so an admin can edit those rows exactly as they edit these.
// hide what it finds, and `to`/`feature` are never its to change (§7).
export const NAV = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Events', to: '/site/events' },
{ 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', feature: 'status' },
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
{ label: 'Market', to: '/site/market', feature: 'market' },
{ label: 'About', to: '/site/about' },
]
@@ -46,28 +48,39 @@ const linkStyle = ({ isActive }) => ({
export default function SiteHeader() {
const { user, loading } = useAuth()
const { siteTitle, settings } = useSite()
const isVisible = useFeatureGate()
// Core's rows plus every installed module's. Computed once: the registry is
// fixed before the first render and there is no unregistering, so this cannot
// change during a session (modules/nav.js).
const baseNav = useMemo(() => withModuleNav(NAV, 'public'), [])
const shardFeatures = useShardFeatures()
// An admin may relabel, reorder and hide these entries from Admin →
// Navigation, and may group them into dropdown sections alongside links of
// their own (THEMING_AND_NAV.md §7). Two things about the order here:
//
// • the override merge runs FIRST and the feature filter after it, so the
// filter stays the boundary — an override cannot un-hide a surface this
// viewer may not see, whatever it says. `pruneNav` applies the same
// filter stays the boundary — an override cannot un-hide a shard surface
// this viewer may not see, whatever it says. `pruneNav` applies the same
// check inside a section and drops one it leaves empty, so a dropdown
// never opens onto nothing;
// • with no stored row this is the coded NAV, in code order, so an
// untouched instance renders exactly what it renders today.
// Installed modules' entries interleave into this list by `order` BEFORE the
// override merge, so an admin edits one nav rather than "core's, plus whatever
// the module appended" — and a module item is hideable and re-labelable
// exactly like a core one. `order` defaults high, which lands module entries
// where the UO items already sat: after the content links, before About.
const base = useMemo(() => {
const items = navFor('public')
if (items.length === 0) return NAV
const merged = [...NAV]
for (const item of items) {
const at = Number.isFinite(item.order) ? item.order : merged.length
merged.splice(Math.min(at, merged.length), 0, { label: item.label, to: item.to, feature: item.feature })
}
return merged
}, [])
const nav = useMemo(() => {
const tree = buildPublicNav(baseNav, parseJsonSetting(settings.nav_public))
return pruneNav(tree, isVisible)
}, [baseNav, settings.nav_public, isVisible])
const tree = buildPublicNav(base, parseJsonSetting(settings.nav_public))
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
}, [base, settings.nav_public, shardFeatures])
// Where the auth entry points: staff → admin, player → portal, else sign in.
let account
@@ -109,10 +122,6 @@ export default function SiteHeader() {
</NavLink>
),
)}
{/* Renders nothing when signed out, so the header keeps its shape for
a visitor. It is here rather than only in the portal because an
inbox item is worth seeing from the page you are already on. */}
{!loading && <NotificationBell />}
{!loading && (
<NavLink
to={account.to}

View File

@@ -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 (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
{sales.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{sales.map((s) => (
<li key={`${s.t}-${s.itemType}-${s.price}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} {Number(s.price || 0).toLocaleString()}gp
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -1,175 +0,0 @@
import { useState } from 'react'
import { api } from '../../api/client.js'
// Self-service email address (engagement Phase 1b). Shared by the player portal
// and the admin account screen, the same way TrustedDevicesPanel and
// RecoveryCodesPanel are — /auth/me/account is one surface for every role, so its
// UI is one component too.
//
// The property this component exists to make visible: a requested address is
// STAGED, not applied. The account keeps receiving mail — password resets
// included — at the address it already has until the emailed link is opened. If
// the UI let a pending address look like the address in force, someone who
// mistyped would believe the change took and would only discover otherwise when
// they could not recover their account.
//
// `hasPassword` decides whether the current-password field appears: an address is
// where account recovery lands, so changing it is re-authenticated, with the same
// carve-out the password form makes for an SSO-only account.
export default function EmailAddressPanel({ account, reload, embedded = false }) {
const hasPassword = account.has_password !== false
const [email, setEmail] = useState('')
const [current, setCurrent] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
const pending = account.email_pending
async function save(e) {
e.preventDefault()
setMsg('')
setError('')
setBusy(true)
try {
const res = await api.changeEmail(email.trim(), hasPassword ? current : undefined)
setEmail('')
setCurrent('')
// Report an unsent mail honestly. Saying "check your inbox" about a message
// that was never sent turns a configuration problem into a user who waits.
if (res.emailed === false) {
setMsg(
res.reason === 'NOT_CONFIGURED'
? 'Address saved, but this site cannot send email right now. Ask an administrator, then use Resend.'
: 'Address saved, but the confirmation email could not be sent. Try Resend in a moment.',
)
} else {
setMsg(
`Confirmation sent to ${res.email_pending}. Your current address stays in use until you open that link.`,
)
}
await reload()
} catch (err) {
if (err.status === 429) setError('Too many confirmation emails. Try again later.')
else setError(err.message || 'Could not change your email address.')
} finally {
setBusy(false)
}
}
async function resend() {
setMsg('')
setError('')
setBusy(true)
try {
const res = await api.resendEmailVerification()
setMsg(
res.emailed === false
? 'Could not send the confirmation email.'
: `Confirmation re-sent to ${res.email_pending}.`,
)
} catch (err) {
setError(err.message || 'Could not resend the confirmation email.')
} finally {
setBusy(false)
}
}
async function discard() {
setMsg('')
setError('')
setBusy(true)
try {
await api.cancelEmailChange()
setMsg('Pending address discarded.')
await reload()
} catch (err) {
setError(err.message || 'Could not discard the pending address.')
} finally {
setBusy(false)
}
}
const wrap = embedded
? {}
: { marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }
return (
<div style={wrap}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Email address
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
{account.email ? (
<>
Currently <strong style={{ color: 'var(--head)' }}>{account.email}</strong>
{account.email_verified ? ' (confirmed)' : ' (not yet confirmed)'}. This is where password-reset
email is sent.
</>
) : (
'You have no email address on file, so you cannot reset your password by email.'
)}
</p>
{pending && (
<div
className="sans"
style={{
border: '1px solid var(--line-soft)',
borderRadius: 6,
padding: '10px 12px',
marginBottom: 16,
fontSize: '0.85rem',
color: 'var(--muted)',
}}
>
<strong style={{ color: 'var(--head)' }}>{pending}</strong> is waiting to be confirmed. It is not in
use until you open the link in that email.
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button type="button" onClick={resend} disabled={busy} className="btn btn-sq">
Resend
</button>
<button type="button" onClick={discard} disabled={busy} className="btn btn-sq">
Discard
</button>
</div>
</div>
)}
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
<label>
<span className="field-label">{pending ? 'Use a different address' : 'New email address'}</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
autoComplete="email"
/>
</label>
{hasPassword && (
<label>
<span className="field-label">Current password</span>
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
className="input"
autoComplete="current-password"
/>
</label>
)}
<div>
<button type="submit" disabled={busy || !email.trim()} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Send confirmation'}
</button>
</div>
{(msg || error) && (
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: error ? '#e08a8a' : 'var(--muted)' }}>
{error || msg}
</p>
)}
</form>
</div>
)
}

View File

@@ -0,0 +1,31 @@
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
// emoji sigil + a ring colour — enough to make the Governors board and the
// governor badge read as distinct "crests" today, swappable for real artwork
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
// public path) onto an entry and update CityCrest to prefer it.
//
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
export const CITY_CRESTS = {
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
}
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
// Look up a crest by the raw city key, tolerating spacing variants
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
export function crestFor(city) {
if (!city) return FALLBACK
const key = String(city).replace(/\s+/g, '')
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
if (crest) return crest
return { ...FALLBACK, label: String(city) }
}

View File

@@ -0,0 +1,72 @@
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
// regions) up into a handful of labelled display buckets for the "Players Online"
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
// membership) and the widget follows. Anything not matched lands in "Wilderness"
// so the bucket counts always reconcile to the true total.
// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped)
// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a
// list rather than one giant alternation regex (simpler to read and retune).
const TOWN_PREFIXES = [
'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia',
'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold',
'wind', 'delucia', 'papua',
]
const normalizeRegion = (r) => String(r).toLowerCase().replace(/['\s]/g, '')
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
// membership. First matching bucket wins; the last bucket is the catch-all.
export const BUCKETS = [
{
id: 'britain',
label: 'Britain',
// Passthrough for the capital + its immediate surrounds.
match: (r) => /^britain/i.test(r),
},
{
id: 'towns',
label: 'Towns',
// The other named cities/towns.
match: (r) => {
const norm = normalizeRegion(r)
return TOWN_PREFIXES.some((t) => norm.startsWith(t))
},
},
{
id: 'dungeons',
label: 'Dungeons',
match: (r) =>
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
r,
),
},
{
id: 'housing',
label: 'Housing',
// House regions expose themselves as named house/townhouse regions.
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
},
{
id: 'wilderness',
label: 'Wilderness',
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
match: () => true,
},
]
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
// order, dropping empty buckets, with the summed total also returned.
export function bucketize(byRegion = {}) {
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
let total = 0
for (const [region, n] of Object.entries(byRegion || {})) {
const count = Number(n) || 0
total += count
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
totals.set(bucket.id, totals.get(bucket.id) + count)
}
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
(r) => r.count > 0,
)
return { rows, total }
}

View File

@@ -1,12 +0,0 @@
// Client email-block registry entrypoint. Importing this module registers every
// `email.*` authoring definition exactly once, then re-exports the registry API.
// The template editor imports from HERE, never from ./registry, so the
// definitions are loaded before anything reads the palette.
//
// Same shape as `blocks/index.js` — and the same reason for existing.
export * from './registry'
export { VariablePalette } from './types.jsx'
// ── Definitions (self-register on import) ──────────────────────────────────
import './types.jsx'

View File

@@ -1,100 +0,0 @@
// ── The client-side `email.*` block registry ───────────────────────────────
//
// ENGAGEMENT.md §4.6.2, Phase 5b. A sibling of `blocks/registry.js` for the same
// reason its server counterpart is a sibling of `blocks/registry.js` on that side
// — and with ONE structural difference that is the whole argument for the shape of
// this screen:
//
// **an email block definition here has no `component`.**
//
// A page block carries a React renderer because a page IS React. A mail body is a
// string this deployment's server produces, and the preview shows exactly that
// string. Giving these entries a React renderer would mean two renderers for one
// artifact — one drawing the editor's preview, one producing what actually lands
// in someone's inbox — and nothing would make them agree. They would agree on the
// day they were written and drift from the first Outlook fix onward, at which
// point the preview becomes a confident lie about mail nobody can see.
//
// So the division is: **this registry owns authoring, the server owns rendering.**
// Everything here is about the editing experience — the palette entry, the prop
// form, the starting props — and the preview arrives from
// `POST /admin/engagement/templates/:id/preview` as HTML that goes into a
// sandboxed iframe.
//
// `type` and `version` must match the server definition in
// `server/src/emailBlocks/types/`. That pairing is the same discipline the page
// family already runs on, and the save is the thing that enforces it: the server
// validates against its own registry, so a client entry that has drifted produces
// a refused save rather than a bad row.
const registry = new Map()
// The same reserved envelope keys the server's `RESERVED_KEYS` names. Duplicated
// rather than imported because the client cannot import from `server/`, exactly as
// `blocks/registry.js` duplicates them — and, as there, the server is the one that
// decides: a block this list let through is still refused at the save.
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
/**
* Register an email block definition.
*
* @param {object} def
* @param {string} def.type must match the server type, e.g. 'email.heading'
* @param {number} def.version must match the server schema version
* @param {string} def.label palette display name
* @param {string} def.icon palette icon glyph
* @param {Function} def.editor ({ props, onChange, variables }) => JSX
* @param {Function} def.defaults starting props when the block is added
*/
export function registerEmailBlock(def) {
if (!def || typeof def.type !== 'string' || !def.type.startsWith('email.')) {
throw new Error('registerEmailBlock: a definition needs a type namespaced "email."')
}
if (registry.has(def.type)) {
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
}
const entry = {
type: def.type,
version: Number.isInteger(def.version) ? def.version : 1,
label: def.label || def.type,
icon: def.icon || null,
// The one-line description under the palette button. Mail blocks are less
// self-evident than page ones — "Item list" does not say that it repeats over
// a variable — and the palette is where that has to be said.
hint: def.hint || '',
editor: def.editor || null,
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
}
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
export function getEmailBlock(type) {
return registry.get(type) || null
}
/** @returns {object[]} every definition, in registration order — the palette. */
export function listEmailBlocks() {
return [...registry.values()]
}
/**
* A fresh block envelope of `type`, ready to push onto the array.
*
* The id is random rather than sequential because block ids are unique across the
* whole document and an operator can delete block 2 and add another; a counter
* would hand out an id that is already taken and the save would be refused for a
* reason nothing on screen explains.
*/
export function newEmailBlock(type) {
const def = getEmailBlock(type)
if (!def) return null
return {
id: `b${Math.random().toString(36).slice(2, 10)}`,
type: def.type,
version: def.version,
visible: true,
props: def.defaults(),
}
}

View File

@@ -1,272 +0,0 @@
// The six `email.*` block editors, in one file rather than one file each.
//
// The page family gives every block its own module because each carries a React
// RENDERER as well as a form, and those are substantial. An email block carries
// only a form — the rendering is the server's (see ./registry.js) — and six short
// prop panels split across six files would be six imports of the same three
// controls to no benefit.
//
// Every `type` and `version` here pairs with a definition in
// `server/src/emailBlocks/types/`, and the field lists are the server's `onlyKeys`
// lists. Where a server schema has a bound (`MAX_TEXT`, `MAX_LABEL`), the input
// carries the same `maxLength` — not as the check, which is the server's, but so
// that an operator meets the limit while typing rather than at the save.
import { TextField, TextAreaField, SelectField, Field } from '../blocks/editorKit.jsx'
import { registerEmailBlock } from './registry'
/**
* The variable palette, rendered under whichever field is being edited.
*
* Clicking a variable APPENDS its token rather than inserting at the caret. That
* is a deliberate simplification: tracking a caret across a controlled React input
* that a parent may re-render costs a ref and a selection-restore on every change,
* and appending is both predictable and trivially undone. §4.6.2's requirement is
* that inserting a variable "writes a token; it is never free-text" — which this
* satisfies — not that it lands at the cursor.
*/
export function VariablePalette({ variables, onInsert }) {
if (!variables || !variables.length) return null
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{variables.map((v) => (
<button
key={v.name}
type="button"
className="btn btn-ghost btn-xs"
title={`${v.type || 'string'}${v.required ? ' · required' : ''}${v.description ? `${v.description}` : ''}`}
onClick={() => onInsert(`{{${v.name}}}`)}
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
>
{v.name}
</button>
))}
</div>
)
}
/** A text field with the palette attached — the shape four of the six blocks want. */
function VariableTextField({ label, hint, value, onChange, variables, maxLength, area, rows }) {
const Control = area ? TextAreaField : TextField
return (
<div>
<Control
label={label}
hint={hint}
value={value}
onChange={onChange}
maxLength={maxLength}
rows={rows}
/>
<VariablePalette variables={variables} onInsert={(token) => onChange(`${value || ''}${token}`)} />
</div>
)
}
registerEmailBlock({
type: 'email.heading',
version: 1,
label: 'Heading',
icon: 'H',
hint: 'A section heading, at one of three sizes.',
defaults: () => ({ level: 'h2', text: 'Heading' }),
editor: ({ props, onChange, variables }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<SelectField
label="Size"
// Named "Size" and not "Level" for the reason the server block's header
// gives: mail clients build no outline from a message, so this is
// typography rather than structure, and calling it a level in the UI would
// invite someone to use it as one.
hint="Mail clients build no document outline, so this is a size, not a rank."
value={props.level || 'h2'}
onChange={(level) => onChange({ ...props, level })}
options={[
['h1', 'Large'],
['h2', 'Medium'],
['h3', 'Small'],
]}
/>
<VariableTextField
label="Text"
value={props.text}
maxLength={200}
variables={variables}
onChange={(text) => onChange({ ...props, text })}
/>
</div>
),
})
registerEmailBlock({
type: 'email.text',
version: 1,
label: 'Paragraph',
icon: '¶',
hint: 'A paragraph of body text.',
defaults: () => ({ text: 'Write your message here.', muted: false }),
editor: ({ props, onChange, variables }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<VariableTextField
label="Text"
area
rows={5}
value={props.text}
maxLength={4000}
variables={variables}
onChange={(text) => onChange({ ...props, text })}
/>
<Field label="Style">
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={Boolean(props.muted)}
onChange={(e) => onChange({ ...props, muted: e.target.checked })}
/>
<span>Quieter for footnotes and small print</span>
</label>
</Field>
</div>
),
})
registerEmailBlock({
type: 'email.button',
version: 1,
label: 'Button / link',
icon: '▭',
hint: 'The call to action. Its plain-text form is a sentence plus the URL.',
defaults: () => ({ label: 'Open', url: '/', textLead: 'Open it here:' }),
editor: ({ props, onChange, variables }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<TextField
label="Button text"
value={props.label}
maxLength={80}
onChange={(label) => onChange({ ...props, label })}
/>
<VariableTextField
label="Link"
hint="Usually a variable, so the link is built for each recipient."
value={props.url}
maxLength={600}
variables={variables}
onChange={(url) => onChange({ ...props, url })}
/>
<TextField
label="Plain-text lead-in"
// The server block's header is worth repeating here in one line, because
// this field looks optional and is the difference between a bare URL and a
// sentence in every text-only inbox.
hint="A button is nothing in plain text. This sentence introduces the link there, e.g. “Choose a new password here:”."
value={props.textLead}
maxLength={200}
onChange={(textLead) => onChange({ ...props, textLead })}
/>
</div>
),
})
registerEmailBlock({
type: 'email.divider',
version: 1,
label: 'Divider',
icon: '—',
hint: 'A horizontal rule.',
defaults: () => ({}),
editor: () => (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
A divider has nothing to configure.
</p>
),
})
registerEmailBlock({
type: 'email.image',
version: 1,
label: 'Image',
icon: '▣',
hint: 'An image by URL. Many clients block images until the reader allows them.',
defaults: () => ({ url: '/brand/logo.png', alt: 'Logo' }),
editor: ({ props, onChange, variables }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<VariableTextField
label="Image URL"
value={props.url}
maxLength={600}
variables={variables}
onChange={(url) => onChange({ ...props, url })}
/>
<TextField
label="Alt text"
hint="Most mail clients block images by default, so for many readers this IS the image."
value={props.alt}
maxLength={200}
onChange={(alt) => onChange({ ...props, alt })}
/>
<Field label="Width" hint="Pixels, 16-560. Leave blank to let the image size itself.">
<input
type="number"
className="input"
min={16}
max={560}
value={props.width ?? ''}
// Blank REMOVES the prop rather than setting it to 0. The server accepts
// `width` absent or between 16 and 560, so a 0 left behind by an empty
// field is a refused save whose message names a field the operator
// believes they cleared.
onChange={(e) => {
const next = { ...props }
const value = Number(e.target.value)
if (!e.target.value || !Number.isFinite(value)) delete next.width
else next.width = Math.trunc(value)
onChange(next)
}}
/>
</Field>
</div>
),
})
registerEmailBlock({
type: 'email.itemList',
version: 1,
label: 'Item list',
icon: '☰',
hint: 'Repeats over a list variable — this is how a digest lists its items.',
defaults: () => ({ variable: '', emptyText: '' }),
editor: ({ props, onChange, variables }) => {
// Only LIST variables may be chosen, and the field is a select rather than a
// text input because this prop is a bare NAME, not a token: a typo here is the
// one variable reference a reader of the template cannot see is wrong, and it
// renders as an empty mail rather than as a visible gap.
const lists = (variables || []).filter((v) => v.type === 'list' || v.type === 'array')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{lists.length ? (
<SelectField
label="List variable"
hint="Each item becomes a row with its heading, excerpt and link."
value={props.variable || ''}
onChange={(variable) => onChange({ ...props, variable })}
options={[['', 'Choose a list…'], ...lists.map((v) => [v.name, v.name])]}
/>
) : (
<Field label="List variable">
<p className="sans dim" style={{ fontSize: '0.85rem', margin: 0 }}>
This templates trigger declares no list variable, so an item list has nothing to
repeat over. Point the template at a trigger that declares one a digest, typically
or use paragraphs instead.
</p>
</Field>
)}
<TextField
label="When the list is empty"
hint="Shown instead of the list. Leave blank to show nothing at all."
value={props.emptyText}
maxLength={200}
onChange={(emptyText) => onChange({ ...props, emptyText })}
/>
</div>
)
},
})

View File

@@ -1,100 +0,0 @@
// Who may see a row of the admin sidebar, and where that lets them go.
//
// Plain JS, in its own file, for two reasons. It is shared — AdminLayout renders
// by it and Admin -> Navigation builds its palette by it (THEMING_AND_NAV.md
// §8.1), and a second copy of this answer is exactly the thing this file exists
// to abolish. And it is the closest thing in the client to an authorization
// decision, so it belongs somewhere the test runner can reach, which a .jsx file
// is not.
//
// **A row's own `roles` is the whole answer.** Until Phase 2 PR 8 this was
// `roles` AND a hardcoded `MOD_PATHS` list of five paths that confined
// moderators, AND a third prefix list in the redirect effect that disagreed with
// both (docs/website/MODULE_SYSTEM.md §1.4). A module's rows could never be
// added to a list core hardcodes, which is what forced the derivation — but the
// lists had already drifted from each other without a module in sight.
/**
* Can a viewer with this role see this row?
*
* Applied AFTER the override merge in both callers: an override is presentation
* and this is the boundary, so an override saying `hidden: false` on a row this
* role cannot see still shows nothing (THEMING_AND_NAV.md §7).
*
* A row with no `roles` is visible to everyone who reached the admin area at
* all — that is the self-service case (Account, My Characters), and staff are a
* superset of players.
*/
export function navItemVisibleTo(item, role) {
return !item.roles || item.roles.includes(role)
}
/**
* The paths a viewer with this role may reach, derived from the rows they see.
*
* Takes the BASE nav, never the override-merged one: an override must not be
* able to move this boundary in either direction. Hiding a row from a
* moderator's sidebar must not also bar them from the page behind it, and
* un-hiding one must not admit them to a page their role does not carry.
*
* @param {Array<{items: Array}>} baseNav the grouped admin nav
* @param {string} role
* @returns {Array<{to: string, exact: boolean}>}
*/
export function allowedPathsFor(baseNav, role) {
return (Array.isArray(baseNav) ? baseNav : [])
.flatMap((g) => g.items || [])
.filter((item) => navItemVisibleTo(item, role))
.map((item) => ({ to: item.to, exact: item.end === true }))
}
/**
* Is this pathname one of them?
*
* A row carrying `end` matches exactly — `/admin` is the dashboard, not a prefix
* of the whole admin area, and treating it as one would let every path through.
* Every other row also covers its sub-routes, which is what keeps
* `/admin/moderation/appeals/12` and a module's detail pages reachable without
* anyone listing them.
*/
export function isAllowedPath(pathname, allowed) {
return (allowed || []).some(({ to, exact }) =>
exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`),
)
}
/**
* The first place in this nav a viewer with this role can actually go.
*
* Added in Phase 3 slice 3, for the player portal, whose index route was
* `PlayerCharacters` — a UO page. When it left, `/player` had nothing behind it,
* and the three ways out were: redirect somewhere fixed, invent a core landing
* page, or resolve the index from the nav the viewer already has. This is the
* third, and it is the only one that keeps today's behaviour — with the module
* installed the first row is still Characters, so a player still lands on their
* characters after signing in, and with nothing installed they land on Account.
*
* **From the BASE nav, never the override-merged one**, the same rule
* `allowedPathsFor` follows and for a sharper version of the same reason: an
* override is presentation, and a landing page is behaviour. An admin reordering
* the sidebar must not silently change where everybody arrives, and — more to
* the point — must not be able to move it somewhere a role cannot follow.
*
* Deliberately generic, and deliberately in this file rather than in the portal
* layout. The admin area has the same shape of question (its index is a
* hardcoded Dashboard), and the direction of travel is one logged-in area that
* shows the right things for the viewer's permissions rather than two that
* duplicate each other. When that happens this is the function it needs, and it
* already answers for both nav shapes.
*
* @param {Array} baseNav flat or grouped, before overrides
* @param {string} role
* @param {string} fallback where to go when the viewer can see nothing at all
*/
export function firstDestinationFor(baseNav, role, fallback) {
const items = (Array.isArray(baseNav) ? baseNav : []).flatMap((entry) =>
entry && Array.isArray(entry.items) ? entry.items : [entry],
)
const first = items.find((item) => item && item.to && navItemVisibleTo(item, role))
return first ? first.to : fallback
}

View File

@@ -1,348 +0,0 @@
// What the Engagement screens say, and what they let an operator choose.
//
// ENGAGEMENT.md Phase 4b. Plain JS in its own file for the reason
// `lib/moduleAdmin.js` is: it is the part of these two screens worth testing, and
// the test runner cannot reach a `.jsx`.
//
// **None of this is a boundary.** `engagementRules.model.js` on the server
// decides what may be saved, and the engine re-checks the audience ceiling again
// at send time. Everything here is an affordance — not offering a choice the
// server is going to refuse, and saying why in the form rather than in a toast.
// The two copies are expected to drift, which is why the server's is the one
// that decides.
//
// The one rule worth stating out loud, because it is the reason the audience
// list is derived rather than hardcoded: **the ceiling vocabulary comes from the
// server** (`GET /admin/engagement/triggers` serves `ceilings`, each with the set
// it `permits`). A second copy of the lattice in the client would be a second
// copy of a security rule, and a second copy is a copy that drifts.
/** A rule row as the API returns it → the shape the form edits. */
export function formFromRule(rule) {
return {
id: rule?.id ?? null,
triggerId: rule?.trigger_id ?? '',
name: rule?.name ?? '',
enabled: Boolean(rule?.enabled),
audience: rule?.audience ?? 'owner',
audienceSegmentId: rule?.audience_segment_id ?? null,
channels: Array.isArray(rule?.channels) ? [...rule.channels] : [],
templateKeys: { ...(rule?.template_keys || {}) },
conditions: rule?.conditions ?? null,
cooldownSeconds: Number(rule?.cooldown_seconds ?? 0),
delaySeconds: Number(rule?.delay_seconds ?? 0),
cancelOn: Array.isArray(rule?.cancel_on) ? [...rule.cancel_on] : [],
maxSendsPerHour: Number(rule?.max_sends_per_hour ?? 100),
}
}
/**
* The form → a POST/PUT body.
*
* `templateKeys` is filtered to the rule's channels rather than sent whole,
* because unticking a channel in the form leaves its template key behind and the
* server refuses a key naming a channel the rule does not have. Dropping it here
* makes unticking a channel do the obvious thing instead of producing an error
* about a field the operator cannot see.
*/
export function ruleToPayload(form) {
const channels = [...new Set(form.channels || [])]
const templateKeys = {}
for (const channel of channels) {
const key = (form.templateKeys || {})[channel]
if (key) templateKeys[channel] = key
}
return {
triggerId: form.triggerId,
name: (form.name || '').trim(),
enabled: Boolean(form.enabled),
audience: form.audience,
audienceSegmentId: form.audienceSegmentId ?? null,
channels,
templateKeys,
conditions: form.conditions ?? null,
cooldownSeconds: Number(form.cooldownSeconds) || 0,
delaySeconds: Number(form.delaySeconds) || 0,
cancelOn: [...new Set(form.cancelOn || [])],
maxSendsPerHour: Number(form.maxSendsPerHour) || 100,
}
}
/**
* Which plain audiences this trigger's ceiling allows, in lattice order.
*
* Derived from the `permits` list the server sends with each ceiling, so a
* trigger declared `owner` offers only `owner` and the editor never presents a
* choice the save is going to refuse. An unknown trigger (a dormant rule whose
* module is gone) offers nothing rather than everything — failing closed is the
* same posture `ceilings.permits` takes on the server.
*/
export function audienceChoicesFor(trigger, ceilings) {
if (!trigger || !Array.isArray(ceilings)) return []
const declared = ceilings.find((c) => c.id === trigger.ceiling)
if (!declared) return []
const allowed = new Set(declared.permits || [])
return ceilings.filter((c) => allowed.has(c.id))
}
/** Segments a rule under this trigger may point at — the same test, on the stored ceiling. */
export function segmentChoicesFor(trigger, ceilings, segments) {
const allowed = new Set(audienceChoicesFor(trigger, ceilings).map((c) => c.id))
return (segments || []).filter((s) => allowed.has(s.ceiling))
}
/**
* The sentence rendered beside a reach preview.
*
* Every branch here exists because the bare number would be a lie in that case:
* a capped count is a floor, an `owner` audience has no advance answer, a dormant
* segment resolves to nobody for a reason worth naming, and a count the trigger's
* ceiling forbids is a number the save is about to refuse.
*/
export function describeReach(preview) {
if (!preview) return ''
const why = operatorWords(preview.reason)
if (preview.dormant) return `Resolves to nobody right now — ${why || 'dormant'}.`
if (preview.permitted === false) {
return `Reaches ${preview.count}, but this trigger does not permit that audience — saving will be refused.`
}
if (why) return `${preview.count} right now — ${why}.`
if (preview.capped) return `At least ${preview.count} people (the preview stops counting there).`
return preview.count === 1 ? '1 person right now.' : `${preview.count} people right now.`
}
/**
* The server says "segment"; these screens say "saved audience".
*
* The API, the schema and the docs all call it a segment and should keep doing
* so - it is one word for one table. But an operator meets the concept here,
* under a heading that says "Audiences", and a sentence that switches vocabulary
* mid-screen reads as a sentence about something else.
*/
export function operatorWords(text) {
if (!text) return text
// Word-wise rather than a regex, so "segmented" and the like are left alone.
const swap = { segment: 'saved audience', segments: 'saved audiences' }
return String(text)
.split(' ')
.map((word) => swap[word] || word)
.join(' ')
}
/**
* The one audience choice that silently reaches nobody, said out loud.
*
* `members` is the ceiling for "a module-declared list". Without a saved
* audience naming WHICH list there is no list, and core knows no game vocabulary
* with which to guess - so the rule resolves to the empty set every time it
* fires. It is also the DEFAULT the moment an operator picks a `members`-ceiling
* trigger, which is what makes it a trap rather than a curiosity: the rule saves,
* switches on, and mails nobody, with nothing on the screen saying so unless the
* operator happens to press Preview.
*
* Returns a sentence, or null when there is nothing to warn about.
*/
export function audienceWarning(form) {
if (!form) return null
if (form.audienceSegmentId) return null
if (form.audience === 'members') {
return 'This reaches nobody as it stands. “Members of a module-declared list” needs a saved audience naming which list.'
}
return null
}
// ── Segment expressions ────────────────────────────────────────────────────
/**
* `not` is legal only as a child of `and` — the server's rule, checked here so
* the composer can grey the button out instead of letting the operator build
* something and then be refused.
*
* The reason, from §5.1a: a complement needs a universe, and the only one that
* does not widen is the set its siblings produced. `A AND NOT B` is "A, less B".
* A bare `NOT B`, or `A OR NOT B`, would have to mean "everyone except…", which
* is a way to build the whole deployment out of one narrow audience.
*/
export function notPlacementError(expression) {
const walk = (node, underAnd) => {
if (!node || typeof node !== 'object') return null
if (!node.op) return null
if (node.op === 'not' && !underAnd) {
return 'An excluded audience can only be used alongside an included one — on its own it would mean “everyone except…”.'
}
// The same rule from the other side: a group of nothing but exclusions has
// no set to take them from. The composer offers "exclude" on every row, so
// this is one checkbox away at all times and is worth saying before the
// round trip - the server refuses it, correctly, but only after a save.
if ((node.op === 'and' || node.op === 'or') && (node.nodes || []).length) {
if ((node.nodes || []).every((c) => c && c.op === 'not')) {
return 'At least one audience has to be included — a list made only of exclusions has nothing to exclude from.'
}
}
for (const child of node.nodes || []) {
const err = walk(child, node.op === 'and')
if (err) return err
}
return null
}
return walk(expression, false)
}
/** A one-line summary of a segment expression, for the list. */
export function describeExpression(node, audiencesById = {}) {
if (!node || typeof node !== 'object') return '—'
if (!node.op) {
const label = audiencesById[node.audienceId]?.label || node.audienceId
const params = Object.entries(node.params || {})
return params.length ? `${label} (${params.map(([k, v]) => `${k}: ${v}`).join(', ')})` : label
}
const parts = (node.nodes || []).map((n) => describeExpression(n, audiencesById))
if (node.op === 'not') return `not ${parts.join(', ')}`
return parts.join(node.op === 'and' ? ' and ' : ' or ')
}
/**
* The one-line summary of a rule, for the list.
*
* `dormant` is deliberately not folded in here — the list renders that as its own
* badge, because "this rule cannot fire" is a different fact from "this is what
* the rule says" and an operator needs both.
*/
export function describeRule(rule, { segmentsById = {} } = {}) {
const parts = []
const audience = rule.audience_segment_id
? segmentsById[rule.audience_segment_id]?.name || `segment ${rule.audience_segment_id}`
: rule.audience
parts.push(`to ${audience}`)
parts.push(`via ${(rule.channels || []).join(', ') || 'no channel'}`)
if (rule.delay_seconds) parts.push(`after ${humanSeconds(rule.delay_seconds)}`)
if (rule.cooldown_seconds) parts.push(`at most once per ${humanSeconds(rule.cooldown_seconds)}`)
parts.push(`${rule.max_sends_per_hour}/hour`)
return parts.join(' · ')
}
// ── Conditions ─────────────────────────────────────────────────────────────
//
// The stored grammar is and/or/not over comparisons; the editor offers the flat
// half of it — one and/or over a list of comparisons — because that is what a
// dropdown-per-operator can render honestly and it covers the rules anyone
// writes by hand.
//
// **A tree the editor cannot render is shown, not silently flattened.**
// Flattening `A AND (B OR C)` into `A AND B AND C` changes which events fire the
// rule, and the operator would have no way to know the save had done it. Such a
// rule opens read-only with its JSON visible and one honest choice: leave it, or
// clear it and start again.
/** Which comparison operators apply to a variable of this declared type? */
export function operatorsForType(operators, type) {
return (operators || []).filter((o) => !type || (o.types || []).includes(type))
}
/**
* A stored conditions tree → the flat rows the editor edits.
*
* `editable: false` means "this file will not pretend it can round-trip that",
* and the screen renders the tree read-only rather than losing part of it.
*/
export function conditionRowsFrom(conditions) {
if (!conditions) return { op: 'and', rows: [], editable: true }
if (conditions.cmp) return { op: 'and', rows: [rowFrom(conditions)], editable: true }
if (conditions.op === 'and' || conditions.op === 'or') {
const children = conditions.nodes || []
if (children.every((n) => n && n.cmp)) {
return { op: conditions.op, rows: children.map(rowFrom), editable: true }
}
}
return { op: 'and', rows: [], editable: false }
}
const rowFrom = (node) => ({
variable: node.variable,
cmp: node.cmp,
// A list operator's value arrives as an array and is edited as comma-separated
// text; everything else is edited as the literal it is.
value: Array.isArray(node.value) ? node.value.join(', ') : node.value === undefined ? '' : String(node.value),
})
/**
* The editor's rows → a conditions tree, with each literal coerced to the type
* the trigger DECLARED for that variable.
*
* The coercion is the point. Every value in an HTML input is a string, and the
* server refuses `{ cmp: 'gt', value: "5" }` against an `int` variable — rightly,
* because a rule whose comparison silently compares a number to a string is a
* rule that quietly never fires. Doing it here means the form's error is about
* something the operator typed rather than about JSON.
*/
export function conditionsFromRows(op, rows, variables) {
const byName = Object.fromEntries((variables || []).map((v) => [v.name, v]))
const nodes = (rows || [])
.filter((r) => r.variable && r.cmp)
.map((r) => {
const type = byName[r.variable]?.type || 'string'
const node = { variable: r.variable, cmp: r.cmp }
if (r.cmp === 'present' || r.cmp === 'absent') return node
if (r.cmp === 'in' || r.cmp === 'nin') {
node.value = String(r.value ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => coerceLiteral(type, s))
} else {
node.value = coerceLiteral(type, r.value)
}
return node
})
if (!nodes.length) return null
if (nodes.length === 1) return nodes[0]
return { op, nodes }
}
/**
* One typed literal out of one string.
*
* A value that does not parse is passed through UNCHANGED rather than turned
* into `NaN` or `false`: the server's type check will then refuse it and name the
* variable, which is a better error than a rule that saves cleanly and compares
* against a number the operator never typed.
*/
export function coerceLiteral(type, raw) {
if (raw === null || raw === undefined) return raw
const text = typeof raw === 'string' ? raw.trim() : raw
switch (type) {
case 'int': {
const n = Number(text)
return Number.isInteger(n) && text !== '' ? n : text
}
case 'float': {
const n = Number(text)
return Number.isFinite(n) && text !== '' ? n : text
}
case 'boolean': {
if (text === true || text === 'true') return true
if (text === false || text === 'false') return false
return text
}
default:
return text
}
}
/** Seconds as the coarsest exact unit — 3600 is "1 hour", 3660 is "61 minutes". */
export function humanSeconds(seconds) {
const n = Number(seconds) || 0
if (n === 0) return 'none'
const units = [
[86_400, 'day'],
[3_600, 'hour'],
[60, 'minute'],
]
for (const [size, name] of units) {
if (n % size === 0) {
const count = n / size
return `${count} ${name}${count === 1 ? '' : 's'}`
}
}
return `${n} seconds`
}

View File

@@ -1,817 +0,0 @@
// ── What the three Events screens say, and what they let staff press ───────
//
// EVENTS.md §I. None of this is a boundary. `events/spec.js` on the server
// decides what may be saved, and the six control statements decide what may
// happen to a run — every one of them is a compare-and-set that re-checks the
// status this file only *predicted*. What is here is the part that would be
// wrong silently: a form that drops an authored step, a params box that posts a
// string where the action declared an int, and above all a console that offers a
// button the server is going to refuse.
//
// **The controls are modelled here rather than inline in the console for one
// reason: they can be tested against the server's rules.** A button that 409s is
// not a bug the way a wrong write is, but it is the failure mode an operator
// meets at 2am while the thing they are trying to stop keeps running — so the
// guards are written twice on purpose and the copy is checked.
// **The condition builder is borrowed, not rebuilt.** §I says the step editor
// reuses "the condition builder, exactly" — and a phase's advance gate is
// literally the engagement grammar, validated on the server by
// `engagement/conditions.js`. Importing the row helpers is what keeps this screen
// from becoming a second opinion about a grammar core owns.
import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js'
// A run that is over. Verbatim `eventRuns.db`'s TERMINAL.
export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed']
export const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status)
/** A step waiting on a human: `running`, with nothing holding it. */
export const isParked = (step) => Boolean(step && step.status === 'running' && step.parked)
/**
* The highest `seq` of a step in this phase that is not still `pending` — the
* furthest the phase has got — or null when none of it has been attempted.
*
* The same rule as the server's `lastStartedSeq`, over the step list the console
* already has, and used only to decide whether to OFFER retry. The near miss is
* worth keeping in view: "the lowest step that is not finished" looks like the
* same thing and is not, because the runner steps OVER a failed step. Under that
* rule a phase that carried on past an `on_failure: skip` failure and then paused
* at a later one would offer retry on the wrong step.
*/
export function lastStartedSeqOf(steps, phase) {
const started = (steps || [])
.filter((s) => s.phase === phase && s.status !== 'pending')
.map((s) => Number(s.seq))
return started.length ? Math.max(...started) : null
}
/**
* Which run-level controls to offer.
*
* `pause` is `starting`/`running` only: a `scheduled` occurrence that should not
* happen is cancelled, not paused. `cancel` is everything non-terminal — "this
* is not happening" is a decision made before a run starts as often as during
* one.
*
* **`advance` is offered only when the phase is genuinely waiting on its gate**,
* which is the same test the server makes and is stated here in the same words
* on purpose: this decides what is *offered*, the server decides what is
* *allowed*, and a button that is present and always refused is the "control
* that answers 409 and does nothing" this feature has refused twice. The gate
* must be open-and-unsatisfied AND no step of the phase may still be pending or
* running — a phase held by a step is held by the step, and skip is its control.
*/
export function runControlsFor(run, gates = [], steps = []) {
if (!run) return { pause: false, resume: false, cancel: false, advance: false }
const terminal = isTerminalRun(run.status)
const gate = (gates || []).find((g) => g.phase === run.currentPhase)
const stepOpen = (steps || []).some(
(s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status),
)
return {
pause: ['starting', 'running'].includes(run.status),
resume: run.status === 'paused',
cancel: !terminal,
advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen,
}
}
/**
* Which step-level controls to offer, for one step of one run.
*
* `retry` carries the guard worth restating: only while the run is PAUSED, only
* on a `failed` step of the phase the run is currently in, and only when that
* step is the furthest one the phase has reached. A failed step under an
* `on_failure` of `skip` is one the run has already moved past, and re-queueing
* it would put a pending row behind the runner's cursor, where it would sit for
* ever.
*/
export function stepControlsFor(run, step, steps) {
const none = { confirm: false, skip: false, retry: false }
if (!run || !step) return none
if (isTerminalRun(run.status)) return none
const parked = isParked(step)
const furthest = step.phase === run.currentPhase ? lastStartedSeqOf(steps, step.phase) : null
return {
confirm: parked,
skip: parked || step.status === 'pending',
retry:
run.status === 'paused' &&
step.status === 'failed' &&
step.phase === run.currentPhase &&
furthest !== null &&
Number(furthest) === Number(step.seq),
}
}
// ── The definition form ────────────────────────────────────────────────────
export const BLANK_PHASE_KEY = 'phase'
const nextPhaseKey = (phases) => {
const used = new Set((phases || []).map((p) => p.key))
for (let n = 1; n < 100; n++) {
const key = n === 1 ? BLANK_PHASE_KEY : `${BLANK_PHASE_KEY}-${n}`
if (!used.has(key)) return key
}
return `${BLANK_PHASE_KEY}-${Date.now()}`
}
/**
* A new step, with its params PREFILLED from the action's declared examples.
*
* Every param carries a required `example` — that requirement is the reason this
* works — so a fresh `core.announce` step arrives with the right keys and
* plausible values rather than empty. Phase 13 turned the box into a form and
* this stayed exactly as it was: a form whose fields start at the declared
* example is a step an author edits rather than one they compose.
*/
export function blankStep(action) {
const params = {}
for (const p of action?.params || []) {
if (p.required || p.example !== undefined) params[p.name] = p.example
}
return {
actionId: action?.id || '',
label: action?.label || '',
onFailure: '',
paramsText: JSON.stringify(params, null, 2),
}
}
export function blankPhase(phases) {
return { key: nextPhaseKey(phases), label: 'New phase', steps: [], advance: blankAdvance() }
}
/**
* The advance gate as the FORM holds it (Phase 5) — three fields that are
* always present and mostly empty, rather than a discriminated union the form
* has to rebuild every time the dropdown moves.
*
* `kind: ''` is "no condition", which is what nearly every phase is and what
* every phase was before this. The form keeps a half-typed `on` gate's trigger
* while the author looks at `after`, because a dropdown that discards what was
* typed under the other option is one an operator learns to be afraid of.
*/
export function blankAdvance() {
return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() }
}
/**
* The `where` predicate as the BUILDER holds it (Phase 13).
*
* `whereText` survives beside the rows and is not vestigial: it is what a
* predicate the builder cannot render is shown as, and what is posted for one.
* See `whereFormFrom`.
*/
export function blankWhere() {
return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' }
}
export const ADVANCE_KINDS = [
{ value: '', label: 'When its steps are done' },
{ value: 'after', label: 'After a fixed delay' },
{ value: 'on', label: 'When something happens in the game' },
]
/** The stored gate, as the form's fields. */
export function advanceFormFrom(advance) {
const blank = blankAdvance()
if (!advance) return blank
if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after }
return {
...blank,
kind: 'on',
on: advance.on || '',
count: advance.count ?? 1,
...whereFormFrom(advance.where),
}
}
/**
* A stored `where` tree → the builder's flat rows (Phase 13).
*
* **This is `conditionRowsFrom` and it is deliberately the same function**, not a
* second one shaped like it. The grammar behind a phase gate is the engagement
* condition grammar — the server validates it with `engagement/conditions.js`
* and renders the diagnosis panel's sentence with the same labels — so an editor
* here that re-decided what a tree looks like would be the second implementation
* §I refuses on the read side for exactly this reason.
*
* A tree the flat editor cannot hold (`A and (B or C)`) comes back
* `whereEditable: false` and is SHOWN as its JSON rather than silently
* flattened: `A and B and C` fires on different events, and an author would have
* no way to know the save had done it to them.
*/
export function whereFormFrom(where) {
const blank = blankWhere()
if (!where) return blank
const rows = conditionRowsFrom(where)
return {
whereOp: rows.op,
whereRows: rows.rows,
whereEditable: rows.editable,
whereText: JSON.stringify(where, null, 2),
}
}
/** The editor's working state, from what `GET /admin/events/:id` returned. */
export function formFromDefinition(event) {
const spec = event?.spec || {}
return {
title: event?.title || '',
summary: event?.summary || '',
body: event?.body || '',
imageUrl: event?.imageUrl || '',
seriesId: event?.seriesId ? String(event.seriesId) : '',
seriesOrder: event?.seriesOrder ?? 0,
concurrencyKey: event?.concurrencyKey || '',
graceSeconds: event?.graceSeconds ?? 900,
timezone: event?.timezone || 'UTC',
// Whether the public calendar announces it (Phase 14a). `?? true` rather
// than `|| true`: a definition an operator has deliberately unlisted sends
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
// Whether the public calendar announces it (Phase 14a). `?? true` rather
// than `|| true`: a definition an operator has deliberately unlisted sends
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
...scheduleFormFrom(spec.schedule),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
label: p.label || '',
advance: advanceFormFrom(p.advance),
steps: (p.steps || []).map((s) => ({
actionId: s.actionId || '',
label: s.label || '',
onFailure: s.onFailure || '',
dormant: Boolean(s.dormant),
actionVersion: s.actionVersion,
paramsText: JSON.stringify(s.params || {}, null, 2),
})),
})),
}
}
/**
* One phase's advance gate, as the spec shape — or null when it has none.
*
* **Whether the predicate is VALID is still the server's answer.** The builder
* coerces each literal to the type the trigger DECLARED — which is not a second
* validator but the thing that makes the first one's error useful: every value
* in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int`
* variable is refused by `engagement/conditions.js`, rightly, at which point the
* author is reading an error about JSON rather than about what they typed.
*
* A predicate the builder could not render round-trips through `whereText`
* unchanged. That is the point of keeping the text: the alternative to posting it
* back verbatim is dropping an author's tree because this screen could not draw
* it.
*/
export function advancePayload(advance, where, errors, variables = []) {
if (!advance || !advance.kind) return null
if (advance.kind === 'after') return { after: advance.after }
const out = { on: advance.on, count: Number(advance.count) || 1 }
if (advance.whereEditable === false) {
const text = String(advance.whereText || '').trim()
if (text) {
try {
out.where = JSON.parse(text)
} catch (err) {
errors.push(`${where}, advance condition: ${err.message}`)
}
}
return out
}
const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables)
if (built) out.where = built
return out
}
/**
* The form, as a request body — or the list of everything wrong with it.
*
* Only the JSON parse is checked here, and only because a params box whose text
* is not JSON cannot be turned into a request at all. **Everything else is left
* to the server**: unknown params, wrong types, missing required ones, bad phase
* keys and duplicate keys all come back from `POST`/`PUT` as a list, and
* re-deciding any of them here would be a second validator drifting from the one
* that matters.
*
* `onFailure` is omitted when the author has not chosen one, so the server
* applies the action's risk-class default rather than being told a value the
* form invented.
*/
export function payloadFromForm(form, { triggersById = new Map() } = {}) {
const errors = []
const phases = (form.phases || []).map((phase, pi) => {
const where = advancePayload(
phase.advance,
`Phase ${pi + 1} "${phase.label || phase.key}"`,
errors,
// The declared types the builder coerces against. A trigger nothing
// registers has none, and every literal then stays the string it was typed
// as — which is right: the gate is dormant, the server carries its `where`
// through unvalidated, and inventing types for it here would edit a
// predicate nobody can currently check.
triggersById.get(phase.advance?.on)?.variables || [],
)
return {
key: phase.key,
label: phase.label,
// Omitted rather than sent as null when there is no gate, which is what
// `events/spec.js` stores for the same reason: a spec full of
// `"advance": null` makes the first phase to gain one look like an edit to
// every phase in the version diff.
...(where ? { advance: where } : {}),
steps: (phase.steps || []).map((step, si) => {
const out = { actionId: step.actionId }
if (step.label) out.label = step.label
if (step.onFailure) out.onFailure = step.onFailure
const parsed = parseParams(step.paramsText)
if (parsed.error) {
errors.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${parsed.error}`)
} else {
out.params = parsed.params
}
return out
}),
}
})
if (errors.length) return { ok: false, errors }
return {
ok: true,
payload: {
title: form.title,
summary: form.summary || null,
body: form.body || null,
imageUrl: form.imageUrl || null,
seriesId: form.seriesId ? Number(form.seriesId) : null,
seriesOrder: Number(form.seriesOrder) || 0,
concurrencyKey: form.concurrencyKey || null,
graceSeconds: Number(form.graceSeconds),
timezone: form.timezone,
listed: Boolean(form.listed),
listed: Boolean(form.listed),
spec: { schedule: scheduleFromForm(form), phases },
},
}
}
// ── The schedule (Phase 4) ─────────────────────────────────────────────────
//
// The four closed shapes of §E, mirrored so the form can render one and the
// preview can describe it. `events/spec.js` and `events/recurrence.js` remain
// the deciders — this is what makes the form a form rather than a text box, and
// it is the whole reason the schedule is not a cron string: a closed set has a
// dropdown, and an operator can proofread a dropdown.
export const WEEKDAYS = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
]
export const SCHEDULE_KINDS = [
{ value: 'manual', label: 'Started by hand' },
{ value: 'once', label: 'Once, at a set time' },
{ value: 'weekly', label: 'Weekly, on chosen days' },
{ value: 'monthly', label: 'Monthly, on the nth weekday' },
]
// 1..4 and "last". There is no fifth: every month has a first through fourth of
// every weekday, and "last" is what a month with five Fridays makes different
// from "fourth" (org lead, 2026-09-02).
export const MONTHLY_NTHS = [
{ value: 1, label: 'First' },
{ value: 2, label: 'Second' },
{ value: 3, label: 'Third' },
{ value: 4, label: 'Fourth' },
{ value: -1, label: 'Last' },
]
const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1)
/**
* A schedule in words, in the event's own zone.
*
* The server says the same thing in `events/recurrence.js#describe`, and the two
* are allowed to differ on wording but not on meaning — this one is what an
* author reads while they are still typing, before anything has been saved.
*/
export function describeSchedule(schedule, timezone = 'UTC') {
if (!schedule || typeof schedule !== 'object') return 'No schedule'
const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth))
switch (schedule.kind) {
case 'manual':
return 'Started by hand — nothing happens until an admin presses Start'
case 'once': {
if (!schedule.at) return 'Once — no date chosen yet'
return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})`
}
case 'weekly': {
const days = (schedule.days || []).map(capitalise)
if (!days.length || !schedule.time) return 'Weekly — choose days and a time'
const list =
days.length === 1
? days[0]
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
return `Every ${list} at ${schedule.time} (${timezone})`
}
case 'monthly': {
if (!nth || !schedule.weekday || !schedule.time) {
return 'Monthly — choose a week, a weekday and a time'
}
return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})`
}
default:
return 'No schedule'
}
}
/**
* The schedule half of the editor's working state.
*
* Every shape's fields are kept side by side rather than cleared when the kind
* changes, so an author who clicks Weekly, then Monthly, then back has not lost
* the days they picked. `scheduleFromForm` reads only the fields the chosen kind
* uses, which is what keeps the request body a clean single shape.
*/
export function scheduleFormFrom(schedule) {
const s = schedule || {}
return {
scheduleKind: s.kind || 'manual',
scheduleAt: s.kind === 'once' ? s.at || '' : '',
scheduleDays: s.kind === 'weekly' ? s.days || [] : [],
scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1',
scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday',
scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00',
}
}
/** The schedule the form describes, as the spec object the server expects. */
export function scheduleFromForm(form) {
switch (form.scheduleKind) {
case 'once':
return { kind: 'once', at: form.scheduleAt }
case 'weekly':
return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime }
case 'monthly':
return {
kind: 'monthly',
nth: Number(form.scheduleNth),
weekday: form.scheduleWeekday,
time: form.scheduleTime,
}
default:
return { kind: 'manual' }
}
}
/**
* What a calendar entry is, and therefore what may be done with it.
*
* A `run` is a row: it has a console and somebody can cancel it. A `projected`
* entry is arithmetic the runner has not reached yet — there is nothing to open
* and nothing to stop, and an operator who treats one as a booking has been
* misled by the UI rather than by the server.
*/
export const isProjected = (entry) => entry?.kind === 'projected'
/** An empty box is `{}`, not a parse error — a step may legitimately take none. */
export function parseParams(text) {
const raw = (text || '').trim()
if (!raw) return { params: {} }
let value
try {
value = JSON.parse(raw)
} catch (err) {
return { error: `the params are not valid JSON (${err.message})` }
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return { error: 'the params must be a JSON object' }
}
return { params: value }
}
// ── Step params, as a form (Phase 13) ─────────────────────────────
//
// §I: the step editor is *"the condition builder, exactly — core serves a
// catalog, the module declared the schema, core renders a form it does not
// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for
// this, and everything the form needs was already in the catalog: a param's
// name, type, whether it is required, its description, its example, and the
// option source behind it.
//
// **The JSON stays as the storage and as the escape hatch, and both halves of
// that matter.** As storage, because `payloadFromForm` already builds a request
// out of it and a second representation would be two things to keep in step. As
// an escape hatch, because a form can only render what the declaration
// describes — and a step may legitimately hold something it does not.
//
// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is
// the reason this reads as a port of it rather than as a new idea: a value the
// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening
// `A and (B or C)` there and dropping an undeclared param here are the same
// mistake — a save that looks clean and means something else.
/** The two ways a step's params are edited. */
export const PARAM_FORM = 'form'
export const PARAM_JSON = 'json'
/**
* Can this step's params be rendered as a form without losing anything?
*
* `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold.
* Three things make one, and none of them is an error — each is a step that has
* to be edited as JSON:
*
* • **the action is dormant.** There is no declaration, so there are no fields.
* A form here would render nothing and look like a step with no params.
* • **a param the action does not declare.** The save refuses it by name, which
* is what the author needs to see — and a form that dropped it would post a
* step that saves cleanly having deleted something they typed.
* • **a value no single control can hold** — an object or an array against a
* scalar declaration.
*/
export function paramsRenderable(action, params) {
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
const declared = new Map((action.params || []).map((p) => [p.name, p]))
for (const [name, value] of Object.entries(params || {})) {
if (!declared.has(name)) {
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
}
if (value !== null && typeof value === 'object') {
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
}
}
return { ok: true }
}
/**
* Which mode should this step open in?
*
* The author's own choice wins whenever the form COULD render the step — an
* author who switched to JSON stays in JSON. What they cannot do is stay in a
* form that would lose something, so an unrenderable step is forced to JSON
* whatever the choice was, and the reason is returned so the screen can say it.
*/
export function paramsMode(step, action) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error }
const renderable = paramsRenderable(action, parsed.params)
if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason }
return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null }
}
/** One declared param's current value, as the control holds it. */
export function paramValue(step, name) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return undefined
return parsed.params[name]
}
/**
* Write one param, and give back the whole box.
*
* **An empty field REMOVES the key rather than posting an empty string**, and
* that is the server's own reading rather than a convenience: `checkParams`
* treats `undefined`, `null` and `''` alike — absent — so a required param left
* blank comes back as *"is required"*, which is the error the author needs,
* instead of as a type complaint about `""`.
*
* **A value that does not parse is passed through as typed.** `coerceLiteral` is
* the engagement builder's, unchanged, and its rule is the one that matters
* here too: half of `-` is not a number, and turning it into `NaN` or `0` while
* somebody is still typing would either post a value they never wrote or make
* the field impossible to type a negative into. The server's type check then
* names the param.
*
* Re-serialising the whole object rather than splicing text, for `pickParam`'s
* reason: a string edit that produced valid-looking JSON with a duplicate key
* would be a value the editor and the server read differently.
*/
export function setParam(step, name, raw, type) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return step?.paramsText || '{}'
const next = { ...parsed.params }
if (raw === '' || raw === undefined || raw === null) delete next[name]
else next[name] = coerceLiteral(type, raw)
return JSON.stringify(next, null, 2)
}
/**
* A stored `datetime` as a `datetime-local` input wants it, and back.
*
* The server normalises a datetime param to an ISO string (`conditions.js`
* `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The
* slice is the whole conversion in one direction; in the other the input's own
* text is a moment `new Date()` parses, so it is posted as typed and the server
* does the normalising — one implementation of what a datetime is, and it is
* not this one.
*/
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
/**
* Everything the meter needs out of the form, and nothing else.
*
* The price route takes a spec, not a definition: no title, no schedule, no
* series. Sending the whole payload would put a document in front of a route
* that reads two fields of it — and would fail the moment the rest of the form
* is mid-edit, which is exactly when the meter is being read.
*
* A step whose params do not parse is sent with none rather than dropped, so a
* half-typed JSON box costs its own step's draw and not the phase's.
*/
export function priceBodyFrom(form) {
return {
phases: (form?.phases || []).map((phase) => ({
key: phase.key || null,
steps: (phase.steps || []).map((step) => ({
actionId: step.actionId || '',
params: parseParams(step.paramsText).params || {},
})),
})),
}
}
/**
* Is this plan worth pricing at all?
*
* A meter that fires on an empty form asks the server what nothing costs, on
* every keystroke of the title field. One step with an action chosen is the
* threshold, because that is the first moment there is an answer.
*/
export const worthPricing = (form) =>
(form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId))
// ── Rendering what happened ────────────────────────────────────────────────
const STATUS_WORDS = {
scheduled: 'Scheduled',
starting: 'Starting',
running: 'Running',
paused: 'Paused',
ending: 'Winding down',
completed: 'Completed',
cancelled: 'Cancelled',
failed: 'Failed',
missed: 'Missed',
}
export const runStatusWord = (status) => STATUS_WORDS[status] || status || 'unknown'
const KIND_WORDS = {
'run.created': 'Occurrence created',
'run.status': 'Run status',
'run.health': 'Health',
'run.blocked': 'Held off',
'phase.entered': 'Phase entered',
'phase.completed': 'Phase completed',
'step.status': 'Step',
'step.retry': 'Step retried',
'step.parked': 'Waiting on a human',
'phase.gate': 'Advance condition set',
'condition.evaluated': 'Condition evaluated',
'phase.advanced': 'Phase advanced',
// Phase 6. "Refused" reads differently from "Step" on purpose: an operator
// scanning a stopped run needs to see that nothing is broken.
'step.refused': 'Refused',
'run.budget': 'Caps',
'version.verified': 'Dry run passed',
// Phase 15. "Reported" rather than "Detail": the line is the module talking
// about its own verb, and every other word here names something core did.
'step.detail': 'Step reported',
note: 'Note',
}
// How deep and how long a module's own `detail` value is allowed to render.
// The dispatcher already caps the whole object at 4KB, so this is about a line
// staying a line — an operator scanning a run's log should not have one row
// wrap eight times because a module answered with an array of forty names.
const DETAIL_LIST_SHOWN = 5
const DETAIL_TEXT_MAX = 80
/**
* One value out of a module's `detail`, as text.
*
* **Core does not interpret these keys and neither does this.** A module wrote
* the object; the console shows it. That is the whole reason the renderer is
* generic rather than a switch — a switch would be core learning a module's
* vocabulary, which is the thing the module system exists to prevent.
*/
function detailValue(value) {
if (value === null || value === undefined) return '—'
if (Array.isArray(value)) {
const shown = value.slice(0, DETAIL_LIST_SHOWN).map(detailValue).join(', ')
return value.length > DETAIL_LIST_SHOWN
? `${shown} and ${value.length - DETAIL_LIST_SHOWN} more`
: shown
}
if (typeof value === 'object') {
// A nested object is rendered by its keys rather than as JSON: an operator
// reading a log wants "granted: 8, missed: 4", not a brace.
return Object.entries(value)
.map(([k, v]) => `${k} ${detailValue(v)}`)
.join(', ')
}
const text = String(value)
return text.length > DETAIL_TEXT_MAX ? `${text.slice(0, DETAIL_TEXT_MAX - 1)}` : text
}
export const logKindWord = (kind) => KIND_WORDS[kind] || kind
/**
* One log line as a sentence.
*
* The `detail` of a human control carries `control` and `by`, which is what
* separates "the runner paused this because a world write failed" from "somebody
* pressed pause" — the two are the same transition and the console has to be
* able to tell them apart at a glance.
*/
export function describeLogLine(line) {
const d = line?.detail || {}
const by = d.by ? ' by staff' : ''
switch (line?.kind) {
case 'run.status':
return d.control
? `${runStatusWord(d.to)}${by}${d.control}${d.reason ? `: ${d.reason}` : ''}`
: `${d.from ? `${runStatusWord(d.from)}` : ''}${runStatusWord(d.to)}${d.because ? ` (${d.because})` : ''}`
case 'run.health':
return `Health is now ${d.to}${d.because ? ` (${d.because})` : ''}`
case 'run.blocked':
return `Held: run ${d.heldBy} has the concurrency key "${d.concurrencyKey}"`
case 'phase.entered':
return `Entered ${line.phase} (${d.steps ?? '?'} steps)`
case 'phase.completed':
return `${line.phase} finished`
case 'step.parked':
return `${d.action} is waiting on a human`
case 'step.retry':
return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}`
case 'step.status':
return d.control
? `${d.action}${d.to}${by}${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
: `${d.action}${d.to}${d.error ? `: ${d.error}` : ''}`
case 'run.created':
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
case 'phase.gate':
return d.kind === 'after'
? `${line.phase} advances ${d.after} after it started`
: `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}`
// Both outcomes are logged, and the near miss is the useful one: it is the
// difference between "the boss did spawn, in the wrong region" and "no boss
// has spawned", which look identical on every other line of this log.
case 'condition.evaluated':
return `${d.trigger} ${d.matched ? 'counted' : 'did not count'}${d.seen} of ${d.needed}${
d.satisfied ? ', condition met' : ''
}`
case 'phase.advanced':
return d.because === 'forced'
? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}`
: `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s`
// Phase 6. `step.refused` is its own kind rather than a `step.status` for a
// reason an operator feels at 2am: a refusal is not a failure, and the line
// has to say which deployment rule stopped it -- the answer to "not enabled"
// is a switch, and the answer to "over the cap" is a number.
case 'step.refused':
return `${d.action} refused: ${d.error}`
case 'run.budget':
return (d.dimensions || [])
.map((x) => `${x.dimension} capped at ${x.cap === null ? 'nothing' : x.cap}${x.from ? ` (${x.from})` : ''}`)
.join(', ') || 'no caps apply to this run'
case 'version.verified':
return `Version ${d.version} passed its dry run — scheduled occurrences may start`
// Phase 15. The one line whose body core did not compose: a module may answer
// a successful step with a `detail` object, and this renders whatever keys it
// put there. `action` is core's own and is pulled out to lead the sentence;
// everything after it is the module's.
//
// **Without this case the row would render as the literal string
// "step.detail"**, because the default below is a kind word and not a
// sentence — which would be the reporting channel existing and showing
// nothing, the exact failure it was built to fix.
case 'step.detail': {
const { action, ...rest } = d
const body = Object.entries(rest)
.map(([key, value]) => `${key}: ${detailValue(value)}`)
.join(', ')
return body ? `${action || 'A step'}${body}` : `${action || 'A step'} reported nothing`
}
default:
return logKindWord(line?.kind)
}
}

View File

@@ -1,99 +0,0 @@
// Rendering an event's instant, shared by the public event screens.
//
// **The split these two functions make is EVENTS.md §I's, and it is the one
// thing about event times that is easy to get wrong.** The server returns UTC
// instants and never guesses the reader's zone. The client places them:
//
// • the DAY an entry is filed under is the reader's own — "what is on this
// month" is a question about the month the person reading is living in;
// • the TIME beside it is always the EVENT's zone, carried on the entry —
// because every listing this feature replaces is written in the shard's
// local zone, and "8pm" means the shard's evening to everyone reading it.
//
// Rendering the time in the reader's zone instead would be defensible and is
// wrong here: a player in Berlin told an American shard's event is at "02:00"
// has been told something true and useless, and told it in a way that makes the
// shard's own announcement look like a mistake.
/** The event's own wall clock, with the zone named so it misreads as nothing. */
export function eventTime(instant, timezone) {
try {
const time = new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(new Date(instant))
return `${time} ${shortZone(timezone)}`
} catch {
// An unknown IANA name throws rather than falling back, and an event whose
// timezone column holds a typo must still render. UTC off the instant is the
// honest answer when the zone cannot be honoured.
return `${new Date(instant).toISOString().slice(11, 16)} UTC`
}
}
/** The zone as a reader recognises it: `America/New_York` → `New York`. */
function shortZone(timezone) {
if (!timezone) return 'UTC'
const tail = String(timezone).split('/').pop()
return tail.replace(/_/g, ' ')
}
/** The reader's own day, for the heading an entry is filed under. */
export function readerDayLabel(instant) {
const d = new Date(instant)
if (Number.isNaN(d.getTime())) return ''
return new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
}).format(d)
}
/** The event's own day and time together, for a page that shows one occurrence. */
export function eventDateTime(instant, timezone) {
const d = new Date(instant)
if (Number.isNaN(d.getTime())) return ''
try {
return `${new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
weekday: 'long',
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(d)} ${shortZone(timezone)}`
} catch {
return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
}
}
// The word beside an entry, for the four public statuses.
//
// **`cancelled` needs the instant, and that is the whole reason this is a
// function rather than a lookup table.** The server publishes `failed` and
// `missed` as `cancelled` too — to a visitor those three are one event, and the
// difference between them is about the deployment — but the three do not share
// one English sentence. "Did not happen" is right for a past occurrence and a
// plain falsehood for a future one, and a run four days out that an operator has
// called off is exactly the common case: the calendar was saying *did not
// happen* about next Friday.
//
// So the tense follows the clock, not the status. A future call-off reads
// **Cancelled**; a past one reads **Did not happen**, which is also the honest
// word for the failed and missed runs folded in with it.
const WORDS = {
live: 'Happening now',
scheduled: 'Scheduled',
completed: 'Finished',
}
export function statusWord(status, scheduledFor, now = Date.now()) {
if (WORDS[status]) return WORDS[status]
if (status !== 'cancelled') return status
const at = new Date(scheduledFor).getTime()
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
}

View File

@@ -67,11 +67,6 @@ export function parseLayout(str) {
// The current hardcoded hero as a HeroLayout, so the page is unchanged until
// staff publish their own. Font sizes use the existing clamp() strings so the
// default stays responsive (editor-created text uses px).
//
// The copy is deliberately game-neutral, and deliberately still copy: this is
// also the starting point the hero editor loads, so an instance that wants to
// name its game says so there, once, and the result is stored — rather than core
// shipping one game's words for every instance to overwrite in source.
export function defaultLayout(teaser, name = 'Runic Gateway') {
return {
version: 1,
@@ -89,9 +84,9 @@ export function defaultLayout(teaser, name = 'Runic Gateway') {
align: 'center',
width: 760,
lines: [
{ text: 'Private game server', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
{ text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
{ text: 'A private world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
{ text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
],
},

View File

@@ -1,252 +0,0 @@
// What an admin should be told about one installed module, and what they may do
// to it — derived, not spelled out at each button.
//
// Phase 4, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.2. Plain JS rather than
// a hook or a chunk of JSX, for the same reason `lib/adminNav.js` is: the test
// runner here has no DOM, and this is the part of the Modules screen that is
// actually worth testing.
//
// **The screen has four sources of truth and they are allowed to disagree**
// (MODULE_SYSTEM.md §2.4, and slice 3 for the fourth):
//
// state what the DATABASE row records — what the operator decided, and
// what the last boot ended up doing
// liveState what the LOADER has mounted in this process and is answering with
// onVolume whether there is still a directory there at all
// declared what this container's MODULES variable asks for — the only one of
// the four that no button on this screen can change
//
// Picking one and rendering it would be simpler and would lie. The case that
// makes this concrete is the one decision 3 creates on purpose: an operator
// disables a module (its onShutdown runs, its routes 404) and then enables it
// again. The row says `enabled`; the loader still says `disabled`, because
// there is no `onBoot` re-dispatch and nothing can start it before a restart.
// It is neither running nor off, and the honest thing to show is "enabled —
// restart to start it".
/**
* The one-line status of a module, and whether that status is waiting on a
* restart.
*
* Ordering matters here. The checks run most-alarming first, so a module whose
* directory has been deleted is described that way rather than by whatever its
* row happens to still say.
*
* @param {object} m a row from GET /admin/modules
* @returns {{ label: string, tone: 'ok'|'warn'|'bad'|'idle', pending: boolean, detail: string }}
*/
export function statusOf(m) {
// Declared by the environment and not there at all: no row, no directory,
// nothing mounted. Every other branch below reads one of those three, so
// without this the screen would describe a module it has never had as though
// a row had gone stale — and the one thing the operator needs, the reason
// resolution failed, would be nowhere.
if (m.declared && !m.onVolume && m.state === null) {
return {
label: 'Declared, not installed',
tone: 'bad',
pending: false,
detail: m.declaredError
? `MODULES asks for v${m.declaredVersion}; the last start could not install it: ${m.declaredError}`
: `MODULES asks for v${m.declaredVersion}. It will be installed when the server next starts.`,
}
}
// Gone from the volume, but still known. Either a hand-deleted directory (the
// boot reconcile marks that `startup_failed`) or an uninstall waiting for its
// restart. Both are "there is nothing to run here".
if (!m.onVolume) {
return {
label: m.state === 'disabled' ? 'Uninstalled' : 'Missing from the volume',
tone: m.state === 'disabled' ? 'idle' : 'bad',
pending: m.liveState !== null,
detail: m.state === 'disabled'
? 'The files are gone. Its data was kept, and reinstalling brings it back.'
: 'A row exists but there is no module directory. Reinstall it, or uninstall to clear the row.',
}
}
// **Installed since this process booted**, and this check has to come before
// the failure one. `liveState` is the loader's record, and the loader scans
// the volume once at require time — so a module that is on the volume NOW and
// has no live record was put there after the scan. Anything the row still says
// about it therefore predates the install and is stale by definition.
//
// Found by the §7.7 browser smoke, and no unit test here had modelled it:
// installing over a row left `startup_failed` by the previous boot rendered
// "Failed at the require stage: module directory not present on the volume"
// one second after the file had been written to the volume — and, because that
// branch is not pending, suppressed the restart banner the install had just
// told the operator to use.
if (m.liveState === null) {
return {
label: 'Restart to start',
tone: 'warn',
pending: true,
detail: 'Installed. It mounts when the server next starts.',
}
}
if (m.state === 'startup_failed' || m.liveState === 'startup_failed') {
return {
label: 'Failed to start',
tone: 'bad',
pending: false,
detail: m.failureReason
? `Failed at the ${m.failureStage || 'unknown'} stage: ${m.failureReason}`
: 'It failed to start and recorded no reason.',
}
}
if (m.state === 'disabled') {
return {
label: 'Disabled',
tone: 'idle',
pending: false,
detail: 'Stopped and switched off. Its routes answer 404 and it stays off across restarts.',
}
}
// The row has been switched on but the loader has not started it — the
// decision-3 case: disable ran its onShutdown, and nothing can start it again
// before a restart.
if (m.liveState !== 'started') {
return {
label: 'Restart to start',
tone: 'warn',
pending: true,
detail: m.liveState === 'disabled'
? 'Enabled, but still stopped in the running server — it cannot be restarted in place.'
: 'Enabled. It mounts when the server next starts.',
}
}
// Running, but not the version that is installed. An upgrade writes new files
// and a new row while the old code stays loaded, so the row's `version` is a
// promise about the next boot rather than a description of this one — and
// "Running v2.0.0" beside a process serving v1.0.0 is the same lie as the
// stale-failure one above, in a different place.
if (m.liveVersion && m.liveVersion !== m.version) {
return {
label: 'Restart to finish upgrading',
tone: 'warn',
pending: true,
detail: `v${m.version} is installed; v${m.liveVersion} is still running.`,
}
}
return {
label: 'Running',
tone: 'ok',
pending: false,
detail: 'Mounted and serving.',
}
}
/**
* What the environment's declaration means for this module, as one sentence — or
* null if nothing declares it.
*
* Kept out of `statusOf` on purpose. A module can be running perfectly while its
* declared upgrade is failing, and collapsing both into one label would have to
* pick which of the two is "the" status. This is a second line, beside the first.
*
* The sentence an operator most needs is the uninstall one: MODULES owns what is
* on the volume and the row owns whether it runs, so uninstalling a declared
* module puts its files back at the next start and leaves it switched off. Files
* reappearing unexplained is exactly the kind of thing that gets debugged for an
* afternoon.
*
* @param {object} m a row from GET /admin/modules
* @returns {{ text: string, tone: 'warn'|'idle' }|null}
*/
export function declarationNoteFor(m) {
if (!m.declared) return null
if (m.declaredError) {
return {
text: `MODULES asks for v${m.declaredVersion} and the last start could not install it: ${m.declaredError}`,
tone: 'warn',
}
}
if (!m.onVolume) {
return {
text:
`MODULES declares v${m.declaredVersion}, so its files come back when the server next starts`
+ (m.state === 'disabled' ? ' — switched off, until you enable it.' : '.'),
tone: 'warn',
}
}
return { text: `Declared by this deployment's MODULES variable at v${m.declaredVersion}.`, tone: 'idle' }
}
/**
* Which actions are offered for a module, and why the others are not.
*
* Returned as a map of `{ shown, reason }` rather than a list of shown actions,
* so a disabled button can say what would make it available. Every rule here
* mirrors one the server enforces — this is presentation, never the boundary.
*
* @param {object} m a row from GET /admin/modules
*/
export function actionsFor(m) {
const running = m.liveState === 'started'
const disabled = m.state === 'disabled'
return {
// Only offered while something is actually running: disabling a module that
// is already stopped has nothing to stop and no guard to flip.
disable: {
shown: !disabled && m.onVolume,
reason: disabled ? 'Already disabled.' : 'Nothing is running to stop.',
},
enable: {
shown: disabled && m.onVolume,
reason: 'Only a disabled module can be enabled.',
},
uninstall: {
shown: m.onVolume,
reason: 'There are no files left to remove.',
},
// The server refuses a standalone purge unless the module is disabled, so
// the button says so rather than offering a click that 409s.
purge: {
shown: m.onVolume && m.canPurge,
enabled: disabled,
reason: !m.canPurge
? 'This module ships no purge.sql, so its data cannot be deleted.'
: 'Disable it first, so nothing is serving out of the tables being dropped.',
},
// A row with no directory is the one thing an uninstall cannot tidy through
// the normal path — offer clearing it instead.
forget: {
shown: !m.onVolume && m.state !== null,
reason: 'The module is still installed.',
},
running,
}
}
/**
* Does anything on this list need a restart before it matches what is running?
*
* Drives the one banner at the top of the screen rather than a badge per row:
* the restart is a property of the SERVER, not of a module, and offering it
* five times would suggest otherwise.
*/
export const needsRestart = (modules) => modules.some((m) => statusOf(m).pending)
/**
* Split a hosts string the way the server will.
*
* Duplicated from `install.parseHosts` deliberately — it is four lines, and the
* alternative is an API round trip to preview what the field is going to mean.
* The server remains the one that decides; this only shows the operator how
* their typing will be read.
*/
export function parseHosts(value) {
return String(value || '')
.split(/[,\s]+/)
.map((h) => h.trim().toLowerCase())
.filter(Boolean)
}

View File

@@ -20,10 +20,7 @@
// Two shapes are supported, because two exist:
// flat [{ to, label, ... }] — public header, player portal
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
// Exported for modules/nav.js, which has to answer the same question about the
// same array a moment earlier — one implementation, so the interleave and the
// merge can never disagree about which shape they are looking at.
export function isGrouped(nav) {
function isGrouped(nav) {
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
}
@@ -204,7 +201,7 @@ export function buildNavRows(baseNav, overrides) {
//
// The base side is restricted to the rows the editor is actually holding: §8.1
// filters the palette to what this admin can themselves see, and an item that
// their role or a module's feature gate kept off the screen is not a reorder.
// their role or a shard feature kept off the screen is not a reorder.
function orderMatchesBase(groups, baseNav) {
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
const base = isGrouped(baseNav)
@@ -408,10 +405,9 @@ export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {})
* Apply the caller's visibility gate — and drop a section it leaves empty.
*
* Kept here rather than in SiteHeader because the empty-dropdown case is the one
* with real correctness risk: a section whose every entry is hidden by a
* module's visibility rules must not render as a menu that opens onto nothing.
* The predicate stays the caller's, so this module still knows nothing about
* what any module gates on.
* with real correctness risk: a section whose every entry is hidden by shard
* visibility must not render as a menu that opens onto nothing. The predicate
* stays the caller's, so this module still knows nothing about shard features.
*
* Added links carry no gate, so they are always visible — see the note above.
*
@@ -490,7 +486,7 @@ export function buildPublicNavOverrides(tree, baseNav, stored = null) {
}
// Carry through an entry for a coded item this admin's palette never showed
// them (feature-gated by its module), so their save does not silently reset it.
// them (shard-feature gated), so their save does not silently reset it.
const { items: storedItems } = unwrapPublic(stored)
for (const [to, entry] of Object.entries(storedItems)) {
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry

View File

@@ -1,35 +0,0 @@
// Where a given account's notification screens live.
//
// **Staff and players reach the same two screens at different paths, and that is
// this file's whole reason to exist.** `/auth/me/notifications` is role-agnostic
// — behind `requireAuth` only, like every other `/auth/me` route — but the WEB
// has two logged-in shells: `RequirePlayer` sends anyone who is not a player to
// the admin area, where staff manage their own account under `/admin/account`.
// So a bell that always pointed at `/account/notifications` would, for every
// staff member, point at a page that redirects.
//
// Discovered in the Phase 7 rig: signed in as an admin, the inbox was simply
// unreachable on the web. Two routes, one pair of components, one mapping here.
export const isStaff = (user) => !!(user && user.role && user.role !== 'player')
/** The inbox — what the bell opens. */
export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/account/notifications')
/** The per-channel preferences screen. */
export const notificationSettingsPath = (user) =>
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'
/**
* This account's own event participation (events Phase 14a).
*
* The third screen to need this mapping, and it needed it for exactly the reason
* the two above did: `GET /player/events/history` is behind `requireAuth` alone,
* self-scoped on `req.user.id` — a staff member has a participation history like
* anyone else, and the group's own header says staff are a superset of players.
* The WEB is what disagrees, because `RequirePlayer` sends them to the login
* page. Found the same way the notifications pair was: signed in as an admin,
* the screen simply redirected.
*/
export const eventHistoryPath = (user) =>
isStaff(user) ? '/admin/events/mine' : '/account/events'

View File

@@ -1,26 +0,0 @@
// The page-body shell core's public pages sit in, as plain JS.
//
// Extracted from PublicLayout.jsx for the reason lib/adminNav.js was: the client
// test runner has no DOM and cannot import a .jsx file at all
// (client/test/moduleRegistry.test.js says the same about modules/shared.js), so
// anything with a rule worth asserting has to live outside the component.
//
// The rule worth asserting here is the fallback. `shell` is part of the module
// contract as of MODULE_API_VERSION 1.5.0 (MODULE_API.md §3.4), which means the
// value can come from a module core has never seen, written against a version of
// this list that is older or newer than the one running. An unknown width must
// therefore still produce a wrapper: a module page at the wrong width looks like
// the site, and a page with no wrapper does not — it renders full-bleed with the
// footer riding up under it, which is the defect the prop exists to fix.
const SHELLS = { narrow: 'shell-narrow', mid: 'shell-mid', wide: 'shell-wide' }
export const SHELL_WIDTHS = Object.keys(SHELLS)
// Returns the className for a page body, or null when no shell was asked for —
// null is "render children bare", which is every core page written before 1.5.0
// and stays the default forever.
export function shellClass(shell) {
if (!shell) return null
return `${SHELLS[shell] || SHELLS.narrow} page-body`
}

View File

@@ -0,0 +1,128 @@
// 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 each event kind, keyed by kind. Each formatter
// takes the payload and returns a string. Conditional suffixes are pulled into
// locals so no template literal is nested inside another.
const DESCRIBERS = {
'vendor.sale': (p) => {
const qty = p.amount > 1 ? ` ×${p.amount}` : ''
return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp`
},
'player.death': (p) => {
const by = p.killer ? ` by ${nameOf(p.killer)}` : ''
return `${nameOf(p.who)} was slain${by}`
},
'player.murdered': (p) => {
const by = p.murderer ? ` by ${nameOf(p.murderer)}` : ''
return `${nameOf(p.victim)} was murdered${by}`
},
'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`,
'skill.gain': (p) => {
const base = p.base != null ? ` (${p.base})` : ''
return `${nameOf(p.who)} gained ${p.skill}${base}`
},
'fame.change': (p) => `${nameOf(p.who)}s fame changed to ${n(p.new)}`,
'karma.change': (p) => `${nameOf(p.who)}s karma changed to ${n(p.new)}`,
'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}`,
'house.decay': (p) => {
const region = p.region ? `${p.region}` : ''
return `${p.name || 'A house'} is now ${p.to || p.stage}${region}`
},
'mob.login': (p) => `${nameOf(p.who)} entered the world`,
'mob.logout': (p) => `${nameOf(p.who)} left the world`,
'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`,
'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`,
'server.shutdown': () => 'Shard shut down',
'server.crashed': (p) => {
const err = p.error ? `: ${p.error}` : ''
return `Shard crashed${err}`
},
'champ.update': (p) => {
const where = p.name || p.type || 'A champion spawn'
if (p.status === 'active' && p.bossUp) {
const boss = p.boss ? ` (${p.boss})` : ''
return `${where}: boss is up${boss}`
}
if (p.status === 'active') {
const level = p.level != null ? ` — level ${p.level}` : ''
return `${where} is active${level}`
}
if (p.status === 'cooldown') return `${where} is on cooldown`
return `${where} is ${p.status || 'idle'}`
},
'champ.remove': () => `A champion spawn ended`,
// Support (help-page) queue + in-game moderation (admin channel only)
'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`,
'page.updated': (p) => {
const claimed = p.handled ? ' (claimed)' : ''
return `Help page from ${nameOf(p.sender)} updated${claimed}`
},
'page.closed': (p) => `Help page ${p.pageId || ''} closed`,
'admin.audit': (p) => {
const on = p.target ? ` on ${p.target}` : ''
const origin = p.origin ? ` [${p.origin}]` : ''
return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}`
},
// Staff / sensitive (admin channel only)
'audit.set': (p) =>
`${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old}${p.new})`,
'audit.command': (p) => {
const args = p.args ? ` ${p.args}` : ''
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}`
},
'cheat.fastwalk': (p) => {
const ip = p.ip ? ` (${p.ip})` : ''
return `Fast-walk flagged: ${nameOf(p.who)}${ip}`
},
'account.login.attempt': (p) => {
const ip = p.ip ? ` from ${p.ip}` : ''
return `Login attempt: ${p.acct}${ip}`
},
'gold.change': (p) => {
const sign = p.delta >= 0 ? '+' : ''
return `${p.acct}: gold ${sign}${n(p.delta)}${n(p.new)}`
},
}
// 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 fmt = DESCRIBERS[ev.kind]
return fmt ? fmt(ev.payload || ev) : 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, ' ')
}

View File

@@ -1,100 +0,0 @@
// What core's Team activity feed SAYS, separated from how it renders
// (docs/website/TEAMS.md §4.3).
//
// Core renders this feed into a slot a MODULE declares on its own page, because
// Teams is a contract primitive and not a surface: core owns the feed, its
// visibility rules and its wording; the module owns the page and the vocabulary
// around it. So this file is deliberately narrow — the roster and index
// presentation that once lived here went with the core Team pages, to whichever
// module renders them.
//
// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same
// reason it was there: a feed that is filtered, or a projection that is stale,
// has to say so in words, and getting that wording right is logic rather than
// markup.
const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */
export function relativeTime(when, now = Date.now()) {
if (!when) return null
const ms = now - new Date(when).getTime()
if (!Number.isFinite(ms)) return null
if (ms < MINUTE) return 'just now'
if (ms < HOUR) {
const n = Math.floor(ms / MINUTE)
return `${n} ${n === 1 ? 'minute' : 'minutes'} ago`
}
if (ms < DAY) {
const n = Math.floor(ms / HOUR)
return `${n} ${n === 1 ? 'hour' : 'hours'} ago`
}
const n = Math.floor(ms / DAY)
return `${n} ${n === 1 ? 'day' : 'days'} ago`
}
/**
* How a public surface describes the projection's freshness (§2.4).
*
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
* debugging a sync. A visitor needs one sentence about whether what they are
* looking at is current, and specifically must never be shown an unconfirmed
* empty projection as though it were a confirmed empty shard.
*/
export function freshnessNote(sync = {}, now = Date.now()) {
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
// deployment with no game module is not a broken one.
if (!sync.configured) return null
if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' }
const ago = relativeTime(sync.lastSyncAt, now)
if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` }
return { tone: 'idle', text: `Last confirmed ${ago}.` }
}
/**
* Group feed items into days, newest first, preserving order within a day (§4.3).
*
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
* property of where the reader is sitting, and a shard's evening raid landing at
* 00:30 UTC belongs on the day the players experienced it.
*/
export function groupByDay(items = [], locale = undefined) {
const days = []
const byKey = new Map()
for (const item of items) {
const date = new Date(item.occurredAt)
if (Number.isNaN(date.getTime())) continue
const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
if (!byKey.has(key)) {
const day = {
key,
label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
items: [],
}
byKey.set(key, day)
days.push(day)
}
byKey.get(key).items.push(item)
}
return days
}
/**
* What to say under a feed that has been filtered.
*
* Only when there is something to say: a caller who saw everything is told
* nothing, and an anonymous caller is invited to sign in rather than simply
* informed that entries exist which they cannot have.
*
* The wording avoids core's own noun. The reader is looking at a page the module
* titled — a guild, a clan — and "this Team" would be core's vocabulary leaking
* onto a surface that deliberately does not use it.
*/
export function activityScopeNote(feed = {}, signedIn = false) {
if (feed.scope !== 'public') return null
return signedIn
? 'Some entries are visible to members only.'
: 'Sign in as a member to see the members-only entries.'
}

View File

@@ -1,140 +0,0 @@
// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md
// §2.4, §2.8, §2.9).
//
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
// splitting here specifically: this screen's job is to tell an operator the
// difference between "the shard has no Teams" and "core has not been able to ask
// for two hours", and those two produce almost the same page. Getting that
// wording right is logic, not markup.
/** Tones the screen uses. Names, not colours — the view maps them. */
export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
/**
* How to describe the projection's freshness.
*
* The four states are genuinely different and an operator needs to tell them
* apart:
*
* - no provider registered — nothing to sync, and not a fault;
* - never synced — core has an empty projection it has never confirmed, which
* must NOT read as "there are no Teams";
* - stale — the projection is real but old, and the reason is usually in
* `lastError`;
* - current.
*/
export function freshnessOf(sync = {}) {
if (!sync.configured) {
return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' }
}
if (!sync.lastSyncAt) {
return {
tone: TONE.bad,
label: 'Never synced',
detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
}
}
if (sync.stale) {
return {
tone: TONE.warn,
label: 'Stale',
detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`,
}
}
return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` }
}
/**
* A short, human age. Deliberately coarse: this exists so a sentence reads
* "confirmed 14 minutes ago", and second-level precision would be false comfort
* about a projection whose interval is fifteen minutes.
*/
export function ago(value) {
if (!value) return 'never'
const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000))
if (seconds < 90) return 'just now'
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes} minutes ago`
const hours = Math.round(minutes / 60)
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
return `${Math.round(hours / 24)} days ago`
}
/** The status pill for one Team row. */
export function statusOf(team = {}) {
if (team.status === 'archived') {
return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' }
}
if (team.hidden && team.hiddenReason === 'reserved_name') {
return { tone: TONE.bad, label: 'Hidden — reserved name' }
}
if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' }
return { tone: TONE.ok, label: 'Public' }
}
/**
* What a staff member is told will happen when they press the button.
*
* The gate is decided server-side from the caller's live role, so this only
* describes it. Saying "Request" to a moderator and "Apply" to an admin is what
* stops the pending result being a surprise.
*/
export function gateLabelFor(role, verb) {
return role === 'admin' ? verb : `Request ${verb.toLowerCase()}`
}
/** The three gated actions, for the note under the buttons. */
export const GATED_NOTE =
'Publishing a game-written name needs an admin: a moderators un-hide or display-name change '
+ 'is filed for approval. Hiding is not gated — suppression is always safe.'
/** A one-line description of a queued request, for the approval queue. */
export function describeRequest(request = {}) {
const payload = parsePayload(request.payload)
const who = request.requested_username || 'a deleted user'
switch (request.action) {
case 'unhide':
return `${who} asks to publish “${request.team_name}`
case 'display_name_override':
return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}`
case 'clear_display_name_override':
return `${who} asks to clear the display name on “${request.team_name}`
default:
return `${who} asks for “${request.action}” on “${request.team_name}`
}
}
/**
* The payload may arrive parsed or as a JSON string depending on the driver, so
* this normalises rather than assuming either. The server has the same note.
*/
export function parsePayload(payload) {
if (payload == null) return {}
if (typeof payload === 'object') return payload
try {
return JSON.parse(payload)
} catch {
return {}
}
}
/**
* How a member's leadership should read.
*
* An override is shown AS an override rather than folded into the answer: staff
* looking at a roster need to see that a decision was made, not a fact that looks
* like the game's.
*/
export function leadershipOf(member = {}) {
if (!member.leaderOverride) {
return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
}
const granted = member.leaderOverride.effect === 'grant'
return {
isLeader: granted,
overridden: true,
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
+ `${member.leaderOverride.reason ? `${member.leaderOverride.reason}` : ''}`
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
}
}

View File

@@ -1,85 +0,0 @@
// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5).
//
// This file is small on purpose. **Almost nothing about the forum is the
// client's to decide**: who may post, who may moderate, whether an image
// renders, and whether a post may be edited are all answered by the server and
// read from the payload. What is left here is the handful of pure functions that
// turn those answers into what a reader sees, and they are extracted so they can
// be tested without a browser.
//
// The one that deserves a second look is `editOfferOpen`. It can only ever take
// an offer AWAY — the server grants the edit and re-derives the window from
// `created_at` when the write arrives. A client that granted one would be
// deciding a time-bounded permission against the clock of the party it bounds.
export const REPORT_REASONS = [
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
['sexual', 'Sexual content'],
['illegal', 'Illegal content'],
['impersonation', 'Impersonation'],
['other', 'Something else'],
]
/**
* Should the Edit control still be offered for this post?
*
* Three states, and the middle one is the reason this exists:
* • the server said no → no offer, and nothing here can create one
* • the server said yes, no deadline (staff) → offer
* • the server said yes with a deadline that has since passed while the page
* sat open → withdraw the offer, rather than leave a button that fails
*/
export function editOfferOpen(post, now = Date.now()) {
if (!post || !post.canEdit) return false
if (!post.editableUntil) return true
const until = new Date(post.editableUntil).getTime()
return Number.isFinite(until) && until > now
}
/**
* Turn a rendered body back into something an author can edit.
*
* The server stores sanitised HTML and generates images at READ time from the
* URLs an author wrote (§5.5.3), so what comes back is not what was typed. The
* `<img>` has to go — it is core's output, not the author's input, and leaving it
* in would let an author "edit" markup they never wrote and cannot control.
* The URL survives as the link text beside it, which is what re-renders.
*/
export function stripToText(html) {
return String(html || '')
.replace(/<img[^>]*>/gi, '')
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
// Entities last: unescaping before tag-stripping would let an escaped
// "&lt;script&gt;" become a real tag the next pass then removes, which is a
// different string from the one the author wrote.
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
// `&amp;` last of all, or "&amp;lt;" would decode two steps into "<".
.replace(/&amp;/g, '&')
.trim()
}
/**
* The one-line summary under a thread's title in the list.
*
* `postCount` counts every post including the opening one, so a discussion's
* REPLY count is one less — and an announcement has no replies to count at all,
* which is why the count is omitted rather than shown as zero.
*/
export function threadSummary(thread) {
const parts = []
if (thread.type === 'announcement') parts.push('Announcement')
parts.push(thread.author)
if (thread.type === 'discussion' && thread.postCount > 1) {
const replies = thread.postCount - 1
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
}
if (thread.status === 'hidden') parts.push('hidden')
return parts.join(' · ')
}

View File

@@ -1,103 +0,0 @@
// What Admin → Teams → Notification bridge decides (TEAMS.md §7.2, phase 8).
//
// The view is a form; these are the rules it applies, extracted for the same
// reason `teamAdmin.js` is: the interesting parts are decisions — when the
// acknowledgement dialog opens, and when a standing acknowledgement stops being
// valid — and a decision embedded in JSX is one nothing can assert on.
//
// **The rules here MIRROR the server's and do not replace them.** The server
// refuses to enable a members-only bridge without the acknowledgement (422)
// whether or not this file ever ran. What is here is so the screen agrees with
// that answer before making the round trip, rather than showing an operator a
// save that fails for a reason the form did not mention.
// Wording an operator reads, per event id the server offers. Presentation, so it
// lives on this side; the one bit that is policy — which events are members-only —
// comes from the server with each event.
export const EVENT_LABELS = {
'team.member.joined': 'New members joined',
'team.leadership.changed': 'Leadership changed',
'team.forum.post': 'New forum post',
'team.announcement': 'Announcement posted',
}
export const eventLabel = (id) => EVENT_LABELS[id] || id
/** A row's identity in a list. `null` and `undefined` are both the default row. */
export const rowKey = (row) =>
(row.team_id === null || row.team_id === undefined ? 'default' : String(row.team_id))
export const isDefaultRow = (row) => row.team_id === null || row.team_id === undefined
export const blankDraft = (teamId = null) => ({
teamId,
events: [],
channelRef: '',
enabled: false,
membersAck: false,
})
export const draftFrom = (row) => ({
teamId: row.team_id ?? null,
events: row.events || [],
channelRef: row.channel_ref || '',
enabled: !!row.enabled,
membersAck: !!row.members_ack,
})
export function appliesToLabel(row, fallback = 'All Teams') {
if (isDefaultRow(row)) return fallback
return row.display_name_override || row.team_name || `Team #${row.team_id}`
}
/** Toggle one event in a draft, preserving order of first selection. */
export const toggleEvent = (draft, id) => ({
...draft,
events: draft.events.includes(id) ? draft.events.filter((e) => e !== id) : [...draft.events, id],
})
/**
* Repointing the row drops a standing acknowledgement, in the SAME place the
* server does.
*
* Leaving the tick showing while the server has already decided to clear it is
* the one way this screen could actively mislead: an operator repoints a row at a
* public channel, sees "members-only destination confirmed" still ticked, and
* believes the confirmation they gave for a private channel covers the new one.
*/
export function setChannel(draft, channelRef) {
if (channelRef === draft.channelRef) return draft
return { ...draft, channelRef, membersAck: false }
}
/** Does this draft carry anything that would publish members-only text? */
export const carriesMembersOnly = (draft, membersOnlyIds) =>
draft.events.some((id) => membersOnlyIds.includes(id))
/**
* Should saving stop and ask first?
*
* Only when ENABLING. A draft that carries forum events but is switched off is a
* configuration being written, not a channel being published to — asking then
* would make an operator confirm something they have not decided to do yet, which
* is how a confirmation dialog becomes a thing people click through.
*/
export const needsAcknowledgement = (draft, membersOnlyIds) =>
!!draft.enabled && carriesMembersOnly(draft, membersOnlyIds) && !draft.membersAck
/** The ids of every event the server flagged as members-only. */
export const membersOnlyIdsOf = (events) => (events || []).filter((e) => e.membersOnly).map((e) => e.id)
/**
* Which Teams may still be given an override, and whether the default is taken.
*
* Offering a Team that already has a row would only produce a save that silently
* overwrote it, since the unique key is (platform, team).
*/
export function availableTargets(rows, teams) {
const taken = new Set(rows.filter((r) => !isDefaultRow(r)).map((r) => r.team_id))
return {
hasDefault: rows.some(isDefaultRow),
teams: (teams || []).filter((t) => t.status === 'active' && !taken.has(t.id)),
}
}

View File

@@ -1,112 +0,0 @@
// What Admin → Teams → Voice channels decides (TEAMS.md §7.3, phase 9).
//
// Extracted for the reason `teamIntegrations.js` is: the interesting parts are
// decisions — when the panel refuses to let voice be switched on, how close the
// guild is to running out of roles, what a row's state actually means to the
// person reading it — and a decision written inline in JSX is one nothing can
// assert on.
//
// **These rules MIRROR the server's and do not replace them.** The server refuses
// to enable voice while the bot cannot manage channels and roles (422) whether or
// not this file ever ran, and the reconciler applies the threshold and the grace
// window regardless of what the screen says. What is here is so the screen agrees
// with those answers before making the round trip.
/** Wording for each state the server can report on a row. */
export const STATE_LABELS = {
none: 'Not provisioned',
active: 'Active',
pending_removal: 'Scheduled for removal',
error: 'Error',
}
export const stateLabel = (state) => STATE_LABELS[state] || state || 'Unknown'
/**
* Is the panel allowed to offer the enable switch?
*
* The preflight answers three separate questions and they fail differently: the
* bot is not connected at all, it is connected but missing a permission, or it
* could not be reached. An operator can act on each of those and they need
* different actions, so the reason is passed through rather than flattened to a
* boolean.
*/
export function enableBlockedReason(preflight) {
if (!preflight) return 'The bots status is unknown.'
if (!preflight.connected) return preflight.reason || 'The Discord bot is not connected.'
if (preflight.missingPermissions && preflight.missingPermissions.length > 0) {
return `The bot is missing ${preflight.missingPermissions.join(' and ')} in this guild.`
}
if (!preflight.ready) return preflight.reason || 'The bot cannot manage channels and roles yet.'
return null
}
// Below this many free roles the panel starts saying so. Not a server rule and
// deliberately not one: it is a warning, and the server's only hard behaviour is
// to refuse the create that would exceed the cap.
const HEADROOM_WARNING = 25
/**
* How much room is left, and whether to say something about it.
*
* The 250-role cap is the ceiling this phase's shape brings with it. Access is a
* per-Team role, so it is not "how big can a Team be" — the old overwrite design's
* limit — but "how many Teams can have voice at all", and the difference matters
* to an operator with sixty guilds on their shard. It is guild-wide and shared
* with every role they created themselves, which is why the count comes from the
* bot rather than from core's own rows.
*/
export function roleHeadroom(preflight) {
if (!preflight || !preflight.roleCap) return null
const used = Number(preflight.roleCount) || 0
const cap = Number(preflight.roleCap)
const free = Math.max(0, cap - used)
return { used, cap, free, tight: free <= HEADROOM_WARNING, exhausted: free === 0 }
}
/** How a row's grace window reads while it is running. */
export function removalCountdown(row, now = new Date()) {
if (!row || row.state !== 'pending_removal' || !row.removeAfter) return null
const ms = new Date(row.removeAfter).getTime() - now.getTime()
if (ms <= 0) return 'due for removal on the next pass'
const days = Math.floor(ms / 86400000)
if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
const hours = Math.max(1, Math.round(ms / 3600000))
return `in ${hours} hour${hours === 1 ? '' : 's'}`
}
/**
* Parse the staff-role field an operator types.
*
* Comma-separated ids, because that is what a person copying role ids out of
* Discord ends up with. Validated rather than filtered, mirroring the server: a
* quietly dropped id is a settings screen showing a save that did not happen.
*/
export function parseStaffRoles(text) {
const parts = String(text || '')
.split(',')
.map((part) => part.trim())
.filter(Boolean)
const bad = parts.filter((part) => !/^[0-9]{5,32}$/.test(part))
return { roles: parts, invalid: bad }
}
export const formatStaffRoles = (roles) => (roles || []).join(', ')
/**
* The sentence under the enable switch, which changes meaning with the state.
*
* "Off" is not "nothing is provisioned": switching voice off suspends the
* reconciler in BOTH directions and leaves existing channels in place, which is
* deliberate — a checkbox must not delete structure in somebody's guild — but it
* is also surprising unless the screen says so.
*/
export function statusSummary(settings, rows) {
const provisioned = (rows || []).filter((row) => row.channelRef).length
if (!settings || !settings.enabled) {
return provisioned > 0
? `Off. ${provisioned} channel${provisioned === 1 ? '' : 's'} remain in Discord and are no longer being kept in step — remove them below if they are not wanted.`
: 'Off. No channels are provisioned.'
}
return `On. Teams with at least ${settings.minMembers} member${settings.minMembers === 1 ? '' : 's'} get a voice channel and a role; ${provisioned} provisioned.`
}

View File

@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client.js'
// Which shard surfaces the current viewer may reach, from
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
// Visibility), so the nav can't be a static list any more.
//
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
// and an out-of-rung one 403s whether or not the link is rendered. So while the
// answer is still in flight we return `null` and callers show their default set
// — better a link that briefly 403s than a nav that flickers in on every load.
//
// Cached module-level: the answer is per-viewer but stable for a session, and
// every consumer would otherwise refetch it on mount.
let cached = null
let inFlight = null
export function resetShardFeatures() {
cached = null
inFlight = null
}
export function useShardFeatures() {
const [features, setFeatures] = useState(cached)
useEffect(() => {
if (cached) return undefined
let alive = true
inFlight =
inFlight ||
api.shard
.features()
.then((data) => {
cached = { level: data.level, set: new Set(data.features || []) }
return cached
})
.catch(() => {
// A failed lookup must not blank the nav — fall back to "show
// everything" and let the server do the gating.
cached = null
inFlight = null
return null
})
inFlight.then((result) => {
if (alive) setFeatures(result)
})
return () => {
alive = false
}
}, [])
return features
}
// Convenience: true when `name` is visible, or when we don't know yet.
export function canSee(features, name) {
return !features || features.set.has(name)
}

View File

@@ -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 }
}

View File

@@ -3,133 +3,25 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot, applyCoreFills, offerCoreFill } from './modules/registry.js'
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
import TeamForumPanel from './modules/TeamForumPanel.jsx'
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
// Installed modules arrive as `<script type="module" src="/modules/<id>/">`
// tags the server injects into the shell (server/src/utils/htmlShell.js), placed
// after this bundle's own tag; module scripts execute in document order, so they
// resolve their externals against the global this call sets up
// (docs/website/MODULE_API.md §3.2).
// Installed modules are `<script type="module" src="/modules/<id>/entry.js">`
// tags the server injects into <head> (server/src/utils/htmlShell.js); module
// scripts are deferred, so they run after this bundle and resolve their
// externals against the global this call sets up.
publishSharedDependencies()
// Core registered a feature provider here until slice 3, under owner id `core`
// and namespace `uo`, so that the seam was exercised by real content from the
// day it was built. That prediction paid out exactly as written: the extraction
// deleted the registration and the hook it named, and SiteHeader was not touched.
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
// Render after DOMContentLoaded rather than immediately.
//
// 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')
// The invite-acceptance page's optional next step. Core owns invites — staff are
// invited too — and owned the game-account step inside them until slice 3, which
// meant core reading a `gameAccountSignup` flag and posting to a shard route.
//
// Named for the place, like the other two: it is "the point after an invite has
// been accepted and before the invitee is sent on", not "create a game account".
// Whether there is a step at all is the filling module's decision, made from
// data core does not have; core renders the shell and a skip control, and hands
// over `onDone`. With the slot unfilled the invitee goes straight to the portal,
// which is what core's own code did whenever the flag was off.
declareSlot('player.invite.accepted')
// Core filled the first two itself until slice 3, with the components that were
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills
// all three, and core's own fills had to go for it to be able to — the first
// fill wins, and core registered first (§3.7).
// ── The inverted direction: core fills a MODULE's slot ─────────────────────
//
// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
// tables, the sync, the access rules and the activity feed; it does not own the
// word for one — a UO shard says guild, and the module that comes after it will
// say clan. So core publishes no Team page and no Team nav row, and the module
// that owns the vocabulary owns the page.
//
// The activity feed is the one piece of that page core cannot hand over: only
// core can resolve whether this viewer is inside the Team, and the public/members
// split is a security boundary. So the module declares the place and core fills
// it. Registered here, applied at mount — `applyCoreFills` runs after every
// module chunk has evaluated, which is the only moment a module-declared slot
// exists to be filled.
//
// **Core offers a CONTRIBUTION and never names a slot.** The module that owns the
// page says where each of these goes, in its own vocabulary, by asking for one on
// `declareModuleSlot`. Naming the slots here instead — which is how this was first
// written — meant core's Team content reached exactly one module: any other game
// declaring a place under its own id got an empty page and no error, because a
// fill nobody asked for is deliberately not an error. It also put a module id
// inside core, in string literals `scripts/checkModuleIdentifiers.js` masks by
// construction and so could never have caught.
//
// Offering something nothing asks for is still not an error: a deployment with no
// game module installed asks for none of these, which is the mirror of an
// unfilled slot rendering nothing.
offerCoreFill('team.activity', TeamActivityFeed)
// The forum is core's for the same reason and goes wherever the module asked for
// it — a SECOND place, in module-uo's case, rather than joining the feed in the
// first: a slot takes one component (first fill wins), and stacking two unrelated
// panels into one contribution would make the module unable to place them
// separately on its own page. It also keeps the two independent — a deployment
// with the forum switched off renders the feed exactly as before.
offerCoreFill('team.forum', TeamForumPanel)
// And the notification control. A third contribution rather than a corner of the
// feed for the same reason there were two: this is an action on the page and the
// other two are content in it, and only the module can say where each belongs on
// a page it owns.
offerCoreFill('team.notify', TeamNotifyToggle)
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
// Deferred scripts — which every `type="module"` script is — execute in document
// order and ALL of them finish before DOMContentLoaded fires. Waiting for that
// event is therefore the guarantee that every installed module has registered
// its routes before React reads the registry: no loading state, no re-render,
// and no ordering race between core's bundle and a module's. A module chunk that
// 404s or throws does not hold the event back, so a broken module costs its own
// pages and not the site.
//
// The readyState check below is `'complete'`, and it is not the obvious
// `'loading'`. A DEFERRED script — which every `type="module"` script is — runs
// after the document has been parsed, so by the time this line executes
// readyState is already `'interactive'`; DOMContentLoaded has NOT fired yet and
// still comes after every deferred script. Testing for `'loading'` therefore
// mounts immediately, before any module chunk has evaluated, and a module's
// routes are missing from the very first render — which looks exactly like a
// module that failed to load: its URL falls through to core's catch-all and
// redirects home. Found by loading a real chunk in a browser; no unit test in
// this repo can see it.
//
// `'complete'` is only reached after `load`, which is strictly later than any
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
// Deferred scripts execute in document order and all of them finish before
// DOMContentLoaded fires. Waiting for that event is therefore the guarantee that
// every installed module has finished registering its routes and nav before
// React reads the registry — no loading state, no re-render, no ordering race
// between core's bundle and a module's. If this bundle happens to evaluate after
// the event has already fired (a cached, fast path), readyState is checked and
// render runs at once.
function mount() {
// Every module chunk has evaluated by now, so any slot a module declared is
// present and core's pending fills can land. Must happen before the first
// render: `extensionFor` is read during render and there is no subscription.
applyCoreFills()
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
@@ -139,8 +31,8 @@ function mount() {
)
}
if (document.readyState === 'complete') {
mount()
} else {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true })
} else {
mount()
}

View File

@@ -1,68 +0,0 @@
// ── <Slot> — 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 = <Extension {...props} />
return <SlotBoundary name={name}>{wrap ? wrap(element) : element}</SlotBoundary>
}

View File

@@ -1,96 +0,0 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { activityScopeNote, freshnessNote, groupByDay } from '../lib/teamActivity.js'
// Core's Team activity feed, rendered into a slot a MODULE declares
// (TEAMS.md Part 4, §3.4 as amended).
//
// **This is the inverted slot direction, and this component is why it exists.**
// The feed is core's: core owns `team_activity`, writes the membership and rename
// items into it, enforces the public/members split, and is the only thing that
// can resolve whether this viewer is inside the Team. None of that is a module's
// to reimplement. But the PAGE is the module's, because Teams is a contract
// primitive and core does not own the word for one — a UO shard says guild, the
// next game will say something else. So the module declares the place and core
// puts the feed in it.
//
// The module passes the Team in ITS OWN vocabulary — `externalId` plus its module
// id — and core resolves the slug. A module never learns core's Team id and never
// needs to: it names the thing the way it already names it.
//
// Everything here degrades to rendering nothing. A slot that throws is contained
// by core's own boundary (Slot.jsx), but a slot that renders an error box would
// still be core putting a defect on a page it does not own — so a failed fetch is
// silence, not a message.
export default function TeamActivityFeed({ externalId, moduleId, limit = 25 }) {
const { user } = useAuth()
const [state, setState] = useState({ loading: true, feed: null, team: null })
useEffect(() => {
let active = true
if (!externalId || !moduleId) {
setState({ loading: false, feed: null, team: null })
return undefined
}
// Two calls because the module names the Team its way and the feed is keyed
// by core's slug. The lookup is core's job precisely so the module does not
// have to hold core's identifiers.
api.teamByExternalId(moduleId, externalId)
.then(async (team) => {
const feed = await api.teamActivity(team.slug, { limit })
if (active) setState({ loading: false, feed, team })
})
.catch(() => { if (active) setState({ loading: false, feed: null, team: null }) })
return () => { active = false }
}, [externalId, moduleId, limit])
const { loading, feed, team } = state
if (loading || !feed) return null
const days = groupByDay(feed.items || [])
const note = team ? freshnessNote(team) : null
const scopeNote = activityScopeNote(feed, Boolean(user))
// Nothing has happened and nothing to explain: render nothing rather than an
// empty heading on someone else's page.
if (days.length === 0 && !scopeNote) return null
return (
<section style={{ marginTop: 26 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', marginBottom: 4 }}>
Recent activity
</h2>
{note && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 12px' }}>{note.text}</p>
)}
{days.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.9rem' }}>Nothing has happened here yet.</p>
)}
{days.map((day) => (
<div key={day.key} style={{ marginBottom: 16 }}>
<h3
className="sans dim"
style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}
>
{day.label}
</h3>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 6 }}>
{day.items.map((item) => (
<li key={item.id} className="sans" style={{ fontSize: '0.92rem', color: 'var(--ink)' }}>
{item.summary}
</li>
))}
</ul>
</div>
))}
{scopeNote && (
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>{scopeNote}</p>
)}
</section>
)
}

View File

@@ -1,754 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import DOMPurify from 'dompurify'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
// Core's Team forum, rendered into a second slot a MODULE declares
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
//
// **Why the forum is core's content on a module's page.** Everything that decides
// who may read a thread is core's — the §2.5 resolver, the grants ledger, the
// member/guest distinction — and none of it is a module's to reimplement. But
// core does not own the word for a Team, so it publishes no Team page: the module
// that says "guild" owns the page and declares a place on it, and core fills the
// place. Same direction as the activity feed, same reason.
//
// **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A
// thread needs to be linkable, and core cannot mount a route for it — the route
// belongs to the module's page. `?thread=12` gives a shareable URL that works
// under whatever path the module chose, with no route of core's anywhere in it,
// and the browser's back button behaves. That is the whole reason this component
// holds a list view and a detail view rather than being two components.
//
// **The image mode is published so this can draw the right composer — never to
// decide what renders.** Post bodies arrive already rendered by the server under
// the current policy (§5.5.3); the mode is read here only to show or hide an
// upload control that would otherwise 404. If the two ever disagree, the server
// is right.
//
// **Phase 5 added discussion, and with it three capabilities this file must not
// invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are
// computed on the server and read here. In particular the edit window is a
// server decision twice over — the read path stamps `canEdit`/`editableUntil` and
// the write re-derives it — because a time-bounded permission must not take its
// clock from the party it bounds. What this file does with `editableUntil` is
// stop OFFERING an edit whose deadline has passed while the page sat open; it
// never grants one.
//
// Like the feed, everything here degrades to rendering nothing. A 404 from the
// thread list is the ordinary case — the forum is switched off, or this viewer
// has no access — and putting an error box on a page core does not own would be
// core reporting its own absence as a defect on someone else's surface.
export default function TeamForumPanel({ externalId, moduleId }) {
const { user } = useAuth()
const { settings } = useSite()
const [params, setParams] = useSearchParams()
const [team, setTeam] = useState(null)
const [state, setState] = useState({ loading: true, forum: null })
const [thread, setThread] = useState(null)
const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null
const openThreadId = params.get('thread')
const imageMode = settings?.teams_forum_images || 'disabled'
const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1'
const loadThreads = useCallback(async (slug) => {
try {
setState({ loading: false, forum: await api.teamForumThreads(slug) })
} catch {
setState({ loading: false, forum: null })
}
}, [])
const loadThread = useCallback(async (slug, id) => {
try {
setThread(await api.teamForumThread(slug, id))
} catch {
setThread(null)
}
}, [])
useEffect(() => {
let active = true
// An anonymous visitor has no forum by definition — every route is behind
// requireAuth — so skip the two calls rather than provoking a 401 per page.
if (!externalId || !moduleId || !user || !forumsEnabled) {
setState({ loading: false, forum: null })
return undefined
}
// The module names the Team its own way; core resolves that to a slug. Same
// two-call shape as the activity feed, and for the same reason: a module
// never has to hold core's identifiers.
api.teamByExternalId(moduleId, externalId)
.then(async (found) => {
if (!active) return
setTeam(found)
await loadThreads(found.slug)
})
.catch(() => { if (active) setState({ loading: false, forum: null }) })
return () => { active = false }
}, [externalId, moduleId, user, forumsEnabled, loadThreads])
useEffect(() => {
let active = true
if (!team || !openThreadId) {
setThread(null)
return undefined
}
api.teamForumThread(team.slug, openThreadId)
.then((t) => { if (active) setThread(t) })
.catch(() => { if (active) setThread(null) })
return () => { active = false }
}, [team, openThreadId])
const openThread = (id) => {
const next = new URLSearchParams(params)
if (id == null) next.delete('thread')
else next.set('thread', String(id))
setParams(next)
}
const { loading, forum } = state
if (loading || !forum) return null
if (openThreadId && thread) {
return (
<ThreadView
slug={team.slug}
thread={thread}
canModerate={forum.canModerate}
imageMode={imageMode}
onBack={() => openThread(null)}
onChanged={() => loadThread(team.slug, thread.id)}
onModerate={async (action) => {
await api.teamForumModerate(team.slug, thread.id, { action })
await loadThreads(team.slug)
openThread(null)
}}
/>
)
}
return (
<section style={{ marginTop: 26 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
Forum
</h2>
{!composing && (
<div style={{ display: 'flex', gap: 8 }}>
{/*
Two buttons, because phase 5 split one capability in two. `canPost`
means "may open a discussion" and every participant may — including a
granted guest with no game character, which is path 3 doing its job.
`canAnnounce` is the leader-only half.
*/}
{forum.canPost && (
<button type="button" className="pill" onClick={() => setComposing('discussion')}>
Start a discussion
</button>
)}
{forum.canAnnounce && (
<button type="button" className="pill" onClick={() => setComposing('announcement')}>
Post an announcement
</button>
)}
</div>
)}
</header>
{composing && (
<Composer
slug={team.slug}
type={composing}
imageMode={imageMode}
onCancel={() => setComposing(null)}
onPosted={async () => {
setComposing(null)
await loadThreads(team.slug)
}}
/>
)}
{forum.threads.length === 0 && !composing && (
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
Nothing has been posted here yet.
</p>
)}
{forum.canModerate && <GuestManager slug={team.slug} />}
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0', display: 'grid', gap: 8 }}>
{forum.threads.map((t) => (
<li key={t.id}>
<button
type="button"
className="sans"
onClick={() => openThread(t.id)}
style={{
background: 'none', border: 0, padding: 0, cursor: 'pointer',
textAlign: 'left', color: 'var(--ink)', font: 'inherit',
}}
>
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
{t.locked && <span className="dim" style={{ marginRight: 6 }} title="Locked">🔒</span>}
<strong>{t.title}</strong>
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
{threadSummary(t)}
</span>
</button>
</li>
))}
</ul>
</section>
)
}
/**
* The leader's grant control — §2.5 path 3, exercised by a leader rather than by
* staff.
*
* Worth being explicit about what this admits someone to and what it does not: a
* grant may name ANY account, including one with no linked game character, and it
* writes nothing but the grants ledger. A guest here never appears on the roster,
* never counts towards the Team's membership, and never becomes eligible for a
* Discord role — an integration cannot verify that an unlinked account is a real
* game member, so it must not hand that account a privilege somewhere
* impersonation has consequences.
*
* A leader is capped; staff are not. The cap is shown rather than only enforced,
* because a leader who hits a limit they were never told about reads it as a bug.
*/
function GuestManager({ slug }) {
const [open, setOpen] = useState(false)
const [data, setData] = useState(null)
const [username, setUsername] = useState('')
const [error, setError] = useState(null)
const load = useCallback(async () => {
try {
setData(await api.teamGrantList(slug))
} catch {
setData(null)
}
}, [slug])
useEffect(() => { if (open) load() }, [open, load])
const add = async (event) => {
event.preventDefault()
setError(null)
try {
await api.teamGrantAdd(slug, { username })
setUsername('')
await load()
} catch (err) {
setError(err.message || 'Could not grant access')
}
}
const revoke = async (userId) => {
setError(null)
try {
await api.teamGrantRevoke(slug, userId)
await load()
} catch (err) {
setError(err.message || 'Could not revoke that')
}
}
if (!open) {
return (
<button type="button" className="pill" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
Forum guests
</button>
)
}
return (
<section style={{ marginTop: 12, padding: 12, border: '1px solid var(--rule, #ccc)', borderRadius: 6 }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Forum guests</h3>
<button type="button" className="pill" onClick={() => setOpen(false)}>Close</button>
</header>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '6px 0 10px' }}>
Guests read and post in this forum without being members of the Team. They do not appear on the
roster and are not counted as members.
{data?.cap ? ` Up to ${data.cap} at a time.` : ''}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 10px', display: 'grid', gap: 6 }}>
{(data?.guests || []).map((g) => (
<li key={g.userId} className="sans" style={{ fontSize: '0.88rem', display: 'flex', gap: 8 }}>
<span>{g.username}</span>
<button type="button" className="pill" onClick={() => revoke(g.userId)}>Remove</button>
</li>
))}
{data && data.guests.length === 0 && (
<li className="sans dim" style={{ fontSize: '0.85rem' }}>No guests yet.</li>
)}
</ul>
<form onSubmit={add} style={{ display: 'flex', gap: 8 }}>
<input
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Account name"
maxLength={32}
required
/>
<button type="submit" className="btn btn-primary btn-sq">Add</button>
</form>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
</section>
)
}
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
// A clock that ticks, so an edit control whose deadline passed while the page
// sat open goes away instead of becoming a button that fails. It only ever
// REMOVES an offer — the server decides whether an edit happens, and re-derives
// the window from created_at when it does.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 30_000)
return () => clearInterval(id)
}, [])
const [replying, setReplying] = useState(false)
return (
<section style={{ marginTop: 26 }}>
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
All threads
</button>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
{thread.title}
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.type === 'announcement' ? 'Announcement · ' : ''}
{thread.author}
{thread.authorDeleted && ' (account removed)'}
{thread.locked && ' · locked'}
</p>
{thread.posts.map((post) => (
<PostView
key={post.id}
slug={slug}
post={post}
canModerate={canModerate}
now={now}
onChanged={onChanged}
/>
))}
{/*
`canReply` is the server's answer to "does this thread take replies right
now", and it folds together the two reasons it might not: an announcement
takes none by TYPE, and a locked thread takes none by STATE. Both are
reported separately above so the reader can see which.
*/}
{thread.canReply && !replying && (
<button type="button" className="pill" onClick={() => setReplying(true)} style={{ marginTop: 4 }}>
Reply
</button>
)}
{thread.canReply && replying && (
<ReplyBox
slug={slug}
threadId={thread.id}
imageMode={imageMode}
onCancel={() => setReplying(false)}
onPosted={async () => {
setReplying(false)
await onChanged()
}}
/>
)}
{!thread.canReply && thread.locked && (
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 10 }}>
This thread is locked. Nobody can reply to it, including staff a moderator who wants the
last word unlocks it first, which leaves a record.
</p>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
<ReportControl
slug={slug}
targetType="team_forum_thread"
targetId={thread.id}
label="Report this thread"
/>
{canModerate && (
<>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.locked ? 'unlock' : 'lock')}>
{thread.locked ? 'Unlock' : 'Lock'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
</>
)}
</div>
</section>
)
}
/**
* One post, with whatever this reader may do to it.
*
* Every capability shown here was decided by the server and is read, not
* computed: `canEdit` and `editableUntil` come stamped on the post, and
* `canModerate` on the thread. The one local judgement is whether an
* already-granted edit window has since elapsed, which can only take an offer
* away.
*/
function PostView({ slug, post, canModerate, now, onChanged }) {
const [editing, setEditing] = useState(false)
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now])
const save = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumEditPost(slug, post.id, { body })
setEditing(false)
await onChanged()
} catch (err) {
setError(err.message || 'Could not save that')
} finally {
setBusy(false)
}
}
const moderate = async (action) => {
setError(null)
try {
await api.teamForumModeratePost(slug, post.id, { action })
await onChanged()
} catch (err) {
setError(err.message || 'Could not do that')
}
}
return (
<article style={{ marginBottom: 16 }}>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
{post.author}
{post.authorDeleted && ' (account removed)'}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
</p>
{editing ? (
<form onSubmit={save} style={{ display: 'grid', gap: 8 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={6}
required
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Save</button>
<button type="button" className="pill" onClick={() => setEditing(false)}>Cancel</button>
</div>
</form>
) : (
<>
{/*
Sanitised on write with the forum's own profile, rendered server-side
under the operator's image policy, and re-sanitised here — the same
defence-in-depth every other body-HTML surface on this site applies
(FiveOnFriday, NewsletterIssue, the rich-text block).
`ADD_ATTR: ['referrerpolicy']` is load-bearing and not a preference.
DOMPurify's default allowlist carries `loading` but NOT
`referrerpolicy`, so a plain sanitize() call silently strips the one
attribute that limits what a remote embed leaks to the host serving it
— the privacy property the admin help text promises an operator. The
<img> itself is core's own output with a fixed attribute set, so
nothing here is widening what an author can write.
*/}
{/* eslint-disable-next-line react/no-danger */}
<div
className="prose"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
/>
</>
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
{!editing && (
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{stillEditable && (
<button
type="button"
className="pill"
onClick={() => { setBody(stripToText(post.body)); setEditing(true) }}
>
Edit
</button>
)}
{/* Reporting your own post is pointless rather than harmful, but
offering it reads as an invitation to misunderstand the control. */}
{!post.mine && (
<ReportControl
slug={slug}
targetType="team_forum_post"
targetId={post.id}
label="Report"
/>
)}
{canModerate && (
<>
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
{post.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
<button type="button" className="pill" onClick={() => moderate('delete')}>Delete</button>
</>
)}
</div>
)}
</article>
)
}
/**
* The report control — the first user-facing report flow this site has ever had.
*
* **It goes to site staff, and it says so.** The gap it closes is that leaders
* moderate their own Team's forum and a Team's leaders are exactly the people who
* will not report their own Team, so telling a member where the report lands is
* not reassurance copy — it is the whole reason the control is worth using in a
* Team whose leadership is the problem.
*
* A report changes nothing about the content, and the confirmation says that too,
* because a member who expects a post to vanish and watches it stay will report
* it again.
*/
function ReportControl({ slug, targetType, targetId, label }) {
const [open, setOpen] = useState(false)
const [reason, setReason] = useState('abuse')
const [detail, setDetail] = useState('')
const [done, setDone] = useState(false)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumReport(slug, { targetType, targetId, reason, detail: detail || undefined })
setDone(true)
setOpen(false)
} catch (err) {
setError(err.message || 'Could not send that')
} finally {
setBusy(false)
}
}
if (done) {
return (
<span className="sans dim" style={{ fontSize: '0.8rem' }}>
Reported to site staff.
</span>
)
}
if (!open) {
return (
<button type="button" className="pill" onClick={() => setOpen(true)}>
{label}
</button>
)
}
return (
<form
onSubmit={submit}
style={{
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
}}
>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
This goes to <strong>site staff</strong>, not to this Team&rsquo;s leaders. Reporting does not
hide or change anything it asks a staffer to look.
</p>
<label className="sans" style={{ fontSize: '0.85rem' }}>
Reason
{' '}
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
{REPORT_REASONS.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
</label>
<textarea
className="textarea"
value={detail}
onChange={(e) => setDetail(e.target.value)}
placeholder="Anything a staffer should know (optional)"
maxLength={500}
rows={3}
/>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
<button type="button" className="pill" onClick={() => setOpen(false)}>Cancel</button>
</div>
</form>
)
}
/** A reply to an open discussion thread. */
function ReplyBox({ slug, threadId, imageMode, onCancel, onPosted }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumReply(slug, threadId, { body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 10 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write a reply. Paste an image URL on its own line to share a picture."
rows={5}
required
/>
{imageMode === 'uploads' && (
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post reply</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
/**
* The upload control, shared by both composers.
*
* The URL goes into the BODY as text, never as an `<img>` tag. The author never
* writes markup here — core decides at render time whether a URL becomes a
* picture, which is what makes the operator's image policy enforceable rather
* than decorative.
*/
function ImageAttacher({ slug, onAttached, onError }) {
const attach = async (event) => {
const file = event.target.files?.[0]
if (!file) return
try {
const { url } = await api.teamForumUpload(slug, file)
onAttached(url)
} catch (err) {
onError(err.message || 'Could not upload that')
}
}
return (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
)
}
function Composer({ slug, type, imageMode, onCancel, onPosted }) {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const isAnnouncement = type === 'announcement'
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
// `type` is always sent explicitly. The server defaults an absent one to
// `announcement` so that a phase-4 client keeps meaning what it meant, and
// relying on that default here would make a discussion depend on a
// compatibility shim.
await api.teamForumPost(slug, { type, title, body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
<input
className="input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
maxLength={200}
required
/>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={isAnnouncement
? 'Write your announcement. Paste an image URL on its own line to share a picture.'
: 'Start the discussion. Paste an image URL on its own line to share a picture.'}
rows={6}
required
/>
{isAnnouncement && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
Announcements cannot be replied to.
</p>
)}
{imageMode === 'uploads' && (
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{isAnnouncement ? 'Post announcement' : 'Start discussion'}
</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}

View File

@@ -1,102 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
// Core's per-Team notification control, rendered into a THIRD slot a module
// declares (TEAMS.md §6.3, phase 6).
//
// **Why this is a slot at all, and why it is the third one.** Teams have no core
// page — the module that owns the vocabulary owns the page — so a control that
// acts on one Team has nowhere of core's to live. The feed and the forum go below
// the module's roster; this goes above it, because muting a guild is an action ON
// the page rather than more content in it, and that is exactly the placement
// decision a module cannot make if core stacks everything into one fill.
//
// **It renders nothing for a viewer who is not in the Team**, including anonymous
// ones, and that is a privacy property rather than a tidiness one: whether a
// notification preference EXISTS for a Team answers "is this person in it", and
// the guild page is public. The server decides — the preference list only contains
// Teams the caller may be notified about — and this file never infers membership
// from anything it can see on the page.
//
// **Muting is per-Team and covers all four streams.** The per-stream on/off lives
// on the account screen, where the catalog does; the thing that could not be
// expressed before phase 6 is "I am in five Teams and want notifications from
// one", and that is the only question this control asks.
export default function TeamNotifyToggle({ externalId, moduleId }) {
const { user } = useAuth()
const [state, setState] = useState({ loading: true, team: null, pref: null })
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
// Anonymous viewers never fetch. The endpoint would 401 harmlessly, but a
// guild page rendering a public roster should not put an authenticated
// request on the wire for every visitor.
if (!user) return setState({ loading: false, team: null, pref: null })
try {
const team = await api.teamByExternalId(moduleId, externalId)
const { teams } = await api.teamNotificationPrefs()
const pref = (teams || []).find((t) => t.teamId === team.id) || null
setState({ loading: false, team, pref })
} catch {
// Same rule as the feed and the forum: this is core's content on a page
// core does not own, so a failure renders nothing rather than putting an
// error box on somebody else's surface.
setState({ loading: false, team: null, pref: null })
}
}, [externalId, moduleId, user])
useEffect(() => { load() }, [load])
const { loading, pref } = state
if (loading || !pref) return null
async function toggle() {
setBusy(true)
// Optimistic, and reconciled from the server's echo rather than assumed: a
// PUT that silently dropped the entry (a Team left in another tab) must not
// leave the control claiming a state the server does not hold.
const next = { ...pref, muted: !pref.muted }
setState((s) => ({ ...s, pref: next }))
try {
const { teams } = await api.setTeamNotificationPrefs([
{ teamId: pref.teamId, muted: next.muted, emailMode: pref.emailMode },
])
const echoed = (teams || []).find((t) => t.teamId === pref.teamId)
if (echoed) setState((s) => ({ ...s, pref: echoed }))
} catch {
setState((s) => ({ ...s, pref }))
} finally {
setBusy(false)
}
}
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'wrap',
margin: '10px 0 0',
fontSize: '0.84rem',
}}
>
<button type="button" onClick={toggle} disabled={busy} className="btn btn-sq">
{pref.muted ? 'Unmute notifications' : 'Mute notifications'}
</button>
<span className="dim">
{pref.muted
? 'You get no notifications about this team.'
: 'You get notifications about this team.'}
</span>
{/* The one link off this control, because "mute" is a blunt answer to a
question the account screen asks properly — which streams, and whether
email is on at all. */}
<Link to="/account/notifications/settings" className="dim">All notification settings</Link>
</div>
)
}

View File

@@ -1,56 +0,0 @@
// Which nav rows a viewer may see, when the answer belongs to a module.
//
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem);
// the contract is docs/website/MODULE_API.md §3.3.
//
// Nine of the sixteen rows in the public header used to carry a `feature`, and
// every one of them was a shard surface an admin can disable or gate to a higher
// audience. The provider that answered those questions moved out with the module
// in Phase 3 slice 3, and core cannot call it directly and still be a core. It
// keeps this generic seam instead, and the module fills it.
//
// **The namespace comes from the registration, not from the string.** A row's
// `feature` is resolved by the provider its OWN module registered, so a module
// author writes `feature: 'status'` exactly as it reads today: nothing parses a
// prefix, and a typo'd namespace is not a thing that can exist. Core's own rows
// carry no `moduleId` and resolve against the owner id `core` — which nothing
// registers now that the shard rows are gone, and that is the correct resting
// state rather than a gap: no core nav row carries a `feature`.
//
// Everything here fails OPEN, and that is deliberate: this is presentation, the
// gate is server-side
// (a disabled feature 404s and an out-of-rung one 403s whether or not a link was
// rendered), so an unknown answer shows the link rather than blanking the nav.
// The one thing a UI mistake must never do here is hide a page from someone
// entitled to it.
/**
* The predicate the layouts filter their nav with.
*
* @param {Map<string, {has: (name: string) => boolean} | null | undefined>} flagsByOwner
* one entry per registered provider, keyed by the id of the module that
* registered it. The value is whatever that provider's hook returned this
* render: a Set-like of the flags this viewer may see, or `null` while the
* answer is still in flight.
* @returns {(item: object) => boolean}
*/
export function buildFeatureGate(flagsByOwner) {
return function isVisible(item) {
if (!item || !item.feature) return true
const owner = item.moduleId ?? 'core'
// No provider for this owner: the row names a flag nothing answers for. That
// is the no-module-installed case — no core row carries a `feature` once the
// module is out — and it is a correct no-op rather than a hidden row.
if (!flagsByOwner || !flagsByOwner.has(owner)) return true
const flags = flagsByOwner.get(owner)
// Still loading, or a provider that returned something unusable. Both are
// "we do not know yet", and both show the link.
if (!flags || typeof flags.has !== 'function') return true
return flags.has(item.feature)
}
}
/** The gate an area with no providers gets: everything is visible. */
export const OPEN_GATE = () => true
export default buildFeatureGate

View File

@@ -1,65 +0,0 @@
import { createContext, useContext, useMemo, useState } from 'react'
import { featureProviders } from './registry.js'
import { buildFeatureGate, OPEN_GATE } from './featureGate.js'
// The React half of the feature seam. The decision logic is featureGate.js,
// which is plain JS and therefore testable in a runner with no DOM; this file is
// wiring, the same split registry.js and shared.js already use.
//
// **Calling a hook per provider inside a loop is the point, and it is legal
// here.** The rules of hooks require the same hooks in the same order on every
// render of a component — not a statically known list. The provider list is
// fixed before the first render (registration happens while module chunks
// evaluate, and main.jsx does not mount until DOMContentLoaded), there is no
// unregistering, and the snapshot below freezes it per component instance
// anyway. So the loop's length cannot change between renders of this provider,
// which is the actual requirement.
//
// A provider hook returns a Set-like of the flags this viewer may see, or `null`
// while it is still fetching. Core knows nothing else about it: what a flag
// means, how it is fetched, and what it is gated on are all the module's.
const FeatureGateContext = createContext(OPEN_GATE)
export function ModuleFeaturesProvider({ children }) {
// Snapshotted once. useState's initialiser runs on the first render only, so
// even a provider that somehow registered late cannot change this instance's
// hook count mid-life — it would be ignored until the next mount, which is a
// far better failure than a crashed render.
const [providers] = useState(featureProviders)
// eslint-disable-next-line react-hooks/rules-of-hooks -- fixed-length list, see above
const values = providers.map((provider) => provider.hook())
const gate = useMemo(
() => {
const byOwner = new Map()
// First registration wins for a given owner: a module that registers two
// namespaces answers its own nav rows from the first, rather than from
// whichever happened to be stored last.
providers.forEach((provider, i) => {
if (!byOwner.has(provider.id)) byOwner.set(provider.id, values[i])
})
return buildFeatureGate(byOwner)
},
// One dependency per provider — a fixed-length list, for the same reason the
// hook loop above is fixed-length.
// eslint-disable-next-line react-hooks/exhaustive-deps
[providers, ...values],
)
return <FeatureGateContext.Provider value={gate}>{children}</FeatureGateContext.Provider>
}
/**
* The predicate to filter nav rows with: `(item) => boolean`, true when the row
* carries no `feature` or when its module says this viewer may see it.
*
* Outside a provider it is the open gate, so a component rendered in isolation
* (a test, a preview) shows its whole nav rather than none of it.
*/
export function useFeatureGate() {
return useContext(FeatureGateContext)
}
export default ModuleFeaturesProvider

View File

@@ -1,174 +0,0 @@
// The interleave of module nav items into core's nav.
//
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.4 states the problem);
// the normative contract is docs/website/MODULE_API.md §3.3.
//
// **Module items join the BASE array, before anything else happens to it.** That
// is the whole design of this file and the override merge next door forces it:
// `applyNavOverrides` / `buildPublicNav` are keyed by `to` and drop any key the
// base array does not declare (lib/navOverrides.js — deliberately, so a deleted
// route cannot leave a stale row doing something unexpected later). Append
// module items *after* that merge and they are unreachable to Admin →
// Navigation: unorderable, unrelabellable, unhideable. Today's UO rows are all
// three of those things, so appending would make the extraction a visible
// regression for every operator who has ever touched their nav.
//
// So the pipeline gains one step at the front and nothing else changes:
//
// withModuleNav(NAV, area) → admin overrides → role/feature filter → rendered
//
// and the filter stays last, which is what keeps it the boundary an override
// cannot cross (THEMING_AND_NAV.md §7). MODULE_API.md §3.3 wrote those last two
// the other way round; the code is right and the contract was amended.
//
// The result is that a module row is, to everything downstream, an ordinary row.
// Nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists.
import { navFor } from './registry.js'
import { isGrouped } from '../lib/navOverrides.js'
// Rows with no group of their own are collected under this key. A Symbol rather
// than a string so it cannot collide with a group an admin or a module names.
const UNGROUPED = Symbol('ungrouped')
/**
* Sort by effective position, where a row that asked for nothing keeps the index
* it already had. Three tie-breaks, in this order: an explicit `order` beats a
* coincidental index (the module said "third", so third), and two explicit
* orders keep registration order, which `navFor` has already put in scan order.
*
* The same rule byOrder/place use in lib/navOverrides.js, and it has to be — an
* admin who then drags that row is editing the position this produced.
*/
function place(entries) {
return entries
.map((entry, index) => ({ ...entry, index }))
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit) || a.index - b.index)
.map(({ item }) => item)
}
function entryFor(item, fallbackKey) {
return { item, key: item.order ?? fallbackKey, explicit: item.order !== undefined }
}
function coreEntries(items) {
return items.map((item, index) => ({ item, key: index, explicit: false }))
}
/** The `to`s a base nav already claims, flat or grouped. */
function claimedPaths(baseNav, grouped) {
return new Set(grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to))
}
/**
* Drop a module row whose `to` is already on the nav, and say so.
*
* Not a policy about where a module may link — it is that `to` is the KEY the
* override layer stores under and React renders by. Two rows sharing one would
* give an admin a single editor row that silently moves both, and a duplicate
* key in the rendered list. Dropping the newcomer keeps core's row, which is the
* one any existing override was written against.
*
* Fail-safe like every other read in this area: the offending row goes, its
* neighbours stay.
*/
function withoutCollisions(items, claimed) {
const out = []
for (const item of items) {
if (!item || typeof item.to !== 'string' || !item.to) continue
if (claimed.has(item.to)) {
console.warn(
`[modules] nav item "${item.to}" from module "${item.moduleId}" collides with an existing row and was dropped`,
)
continue
}
claimed.add(item.to)
out.push(item)
}
return out
}
// The flat navs — the public header and the player portal.
//
// No groups, so `order` is a position in the one list: core rows are keyed by
// their index and a module row by the `order` it asked for. A module row with no
// order appends after the coded ones, in registration order, rather than jumping
// to the front on a 0 default — the same choice buildPublicNav makes for an
// admin-created link.
function mergeFlat(baseNav, items) {
return place([...coreEntries(baseNav), ...items.map((item, i) => entryFor(item, baseNav.length + i))])
}
// The grouped nav — the admin sidebar.
//
// `group` names an existing core group and the row lands inside it: Moderation
// and System, where today's UO rows already sit (§1.4). An unknown group name
// creates a group at the end rather than dropping the row — a typo must cost a
// position, never a link. A row with no `group` at all lands in a trailing
// untitled group, which renders as ungrouped links; core does not invent a
// display title out of a module id.
//
// An ungrouped row is NOT folded into one of core's own untitled groups
// (Dashboard's, Account's): those are furniture pinned to the top and bottom of
// the sidebar, and a module page does not belong beside "Account".
//
// A group created here is a group as far as everything downstream is concerned,
// including as a destination in Admin → Navigation's "move to section" control:
// `readOverrides` builds its set of legal destinations from the base nav it is
// handed, which is this one.
function mergeGrouped(baseNav, items) {
const titles = new Set(baseNav.map((g) => g.title).filter((t) => typeof t === 'string'))
const into = new Map() // existing group title → rows
const fresh = new Map() // new group title (or UNGROUPED) → rows, first-seen order
for (const item of items) {
const named = typeof item.group === 'string' && item.group ? item.group : null
const key = named ?? UNGROUPED
const bucket = named !== null && titles.has(named) ? into : fresh
if (!bucket.has(key)) bucket.set(key, [])
bucket.get(key).push(item)
}
const kept = baseNav.map((g) => {
const incoming = into.get(g.title)
if (!incoming) return g
return {
...g,
items: place([...coreEntries(g.items), ...incoming.map((item, i) => entryFor(item, g.items.length + i))]),
}
})
const created = [...fresh.entries()].map(([key, rows]) => {
const items_ = place(rows.map((item, i) => entryFor(item, i)))
return key === UNGROUPED ? { items: items_ } : { title: key, items: items_ }
})
return [...kept, ...created]
}
/**
* The base nav a layout should render: core's coded array with every installed
* module's rows for this area interleaved into it.
*
* Returns `baseNav` ITSELF when no module registered anything for this area, so
* an instance with no modules installed renders the identical array it renders
* today — the same "untouched path" guarantee applyNavOverrides makes, and what
* makes a `useMemo` with an empty dependency list around this call honest.
*
* Safe to call once per component and cache: registration completes before the
* first render (main.jsx waits for DOMContentLoaded — MODULE_API.md §3.1) and
* there is no unregistering, so this answer cannot change during a session.
*
* @param {Array} baseNav the coded NAV, flat or grouped
* @param {'public'|'admin'|'player'} area
* @returns {Array} a nav of the same shape
*/
export function withModuleNav(baseNav, area) {
if (!Array.isArray(baseNav)) return []
const grouped = isGrouped(baseNav)
const items = withoutCollisions(navFor(area), claimedPaths(baseNav, grouped))
if (items.length === 0) return baseNav
return grouped ? mergeGrouped(baseNav, items) : mergeFlat(baseNav, items)
}
export default withModuleNav

View File

@@ -1,39 +1,23 @@
// ── The client-side module registry ────────────────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
// A module's prebuilt chunk registers its routes, nav entries and feature
// provider here, and App.jsx / the nav components read them back. This is the
// client half of docs/website/MODULE_API.md §3.3.
//
// A module's prebuilt chunk registers its routes, its nav entries and its feature
// provider here, and core reads them back. This is the client twin of the
// server's modules/loader.js — with one structural difference worth stating,
// because it is what makes the file this short: core *hands* the registry to the
// module (on `window.__rg`, see shared.js) rather than discovering it. There is
// nothing to scan, nothing to validate a manifest against, and no failure mode
// where half a module is registered.
// Timing is the whole design. Module chunks are `<script type="module" src>`
// tags injected into <head> by the server (utils/htmlShell.js). Module scripts
// are deferred, so they evaluate after the SPA's own bundle has run — which is
// where window.__rg is published — and before DOMContentLoaded. main.jsx waits
// for that same event before calling render(), so registration is complete
// before React reads any of this and there is no re-render to orchestrate.
//
// **Timing is the whole design.** Module chunks are `<script type="module" src>`
// tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
// core's own bundle. Module scripts are deferred, so they evaluate after that
// bundle has run — which is where `window.__rg` is published — and all of them
// finish before DOMContentLoaded. main.jsx waits for that same event before
// calling render(), so registration is complete before React reads any of this.
//
// That is what buys the simplicity here: registration is a plain synchronous
// write with no subscribers, not an observable store, because nothing can
// register after the first render. If that ever stops being true it changes in
// this file and in main.jsx, not in a dozen consumers.
//
// What PR 7 wires up is `routesFor` (App.jsx). `navFor` and `featureProviderFor`
// are stored and returned faithfully but core does not read them yet — PR 8 adds
// the nav interleave and the feature-provider seam. Storing them is not the kind
// of accepting stub the server's registries refused to be: nothing is discarded
// here, so a module that registers nav in this core gets it back from `navFor`.
// Registration is therefore a plain synchronous write with no subscribers, not
// an observable store. If that ever changes, it changes here and not in twelve
// consumers.
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
// slot name → { Component, filledBy }.
const slots = new Map()
const featureProviders = new Map()
const registered = new Set()
const AREAS = ['public', 'admin', 'player']
@@ -43,26 +27,18 @@ function assertArea(area, call) {
}
/**
* Route components, by area.
*
* @param {string} id the module id — the URL segment its routes are namespaced under
* Route components for one area.
* @param {string} id the module id, used to namespace the URL segment
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
* each entry `{ path, element, gate? }`. `path` is relative to the module's
* namespace; core prefixes it and mounts it inside the area's existing wrapper
* (`/<id>/…` under MaintenanceGate, `/admin/<id>/…` under RequireAuth +
* AdminLayout, `/player/<id>/…` under RequirePlayer + PlayerPortalLayout).
* `gate` is an optional `{ roles: [...] }` that core applies as its own
* RoleGate — a module cannot supply an auth wrapper, because the sidebar and
* the route table have to agree about who may see what (§3.3).
* each entry `{ path, element, gate? }`; `path` is relative to the module's
* namespace and core prefixes it (`/uo/…`, `/admin/uo/…`, `/player/uo/…`)
*/
export function registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
assertArea(area, 'registerRoutes')
for (const route of list || []) {
// Prefixed HERE rather than by the module: a module cannot claim a path
// outside its own namespace however it spells `path` — a leading `/`, a
// trailing one, or several — because it never gets to write the segment
// its routes hang under.
for (const route of list) {
// Prefixed here rather than by the module, so a module cannot claim a path
// outside its own namespace however it spells `path`.
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
routes[area].push({ ...route, path, moduleId: id })
}
@@ -71,276 +47,52 @@ export function registerRoutes(id, byArea) {
}
/**
* Nav entries, interleaved into CORE groups rather than appended as a block.
*
* Today's UO items sit inside core's own Moderation and System groups; a "UO"
* group at the bottom of the sidebar would be a visible regression on the day
* the module is extracted (MODULE_SYSTEM.md §1.4). `group` names an existing
* core group, `order` sorts within it, and an unknown group name appends rather
* than dropping the item — a mis-typed group must cost a position, never a link.
*
* `icon` is a component core renders exactly as it renders its own rows' icons
* (1.3.0). It exists because without it the six UO rows would have extracted as
* the only text-only entries in a sidebar where every other row has a glyph,
* which reads as breakage rather than as a design. Core does not supply a
* fallback: a module that omits it gets no icon, the same as a core row that
* omits it, and inventing one would be core making a presentation choice for
* content it knows nothing about. Note that `icon` is already among the fields
* an override may not touch (lib/navOverrides.js) — the concept predates a
* module being able to supply one.
*
* Nav entries, interleaved into CORE groups rather than appended as a block
* today's UO items sit inside core's Moderation and System groups, and a "UO"
* group at the bottom would be a visible regression (MODULE_SYSTEM.md §1.4).
* @param {string} id
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?, icon?}>}} spec
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec
*/
export function registerNav(id, spec) {
const { area, items } = spec || {}
assertArea(area, 'registerNav')
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
registered.add(id)
}
/**
* The hook that answers "which of this module's features may this viewer see".
*
* Core keeps a generic flag context and owns none of the semantics
* (MODULE_SYSTEM.md §1.5). With no module installed the nav filter is a correct
* no-op, because no core nav item carries a `feature` — which has been literally
* true since Phase 3 slice 3 took the nine shard-gated rows out.
* Core keeps a generic flag context and owns none of the semantics; with no
* module installed the nav filter is a correct no-op, because no core nav item
* carries a `feature` today (MODULE_SYSTEM.md §1.5).
*/
export function registerFeatureProvider(id, namespace, hook) {
providers.set(namespace, { id, hook })
registered.add(id)
featureProviders.set(namespace, { id, hook })
}
// ── 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 name>` (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 })
}
/**
* The contributions core has for a module-declared slot.
*
* **Core offers a CONTRIBUTION, not a slot name, and that is the whole of why
* this list exists.** The first cut of the inverted direction had core fill three
* literal names — `uo.guild.detail` and its two siblings — which worked for
* exactly one module and silently did nothing for any other: a second game
* declaring `clan.detail` under its own id got an empty page and no error,
* because "a fill for a slot nobody declared is not an error" is the rule that
* makes an unknown name invisible. It also put a module identifier in core, in
* three string literals `scripts/checkModuleIdentifiers.js` cannot see, since it
* masks string bodies by construction.
*
* So the module says WHERE (its own slot, in its own vocabulary) and WHICH of
* core's contributions goes there. Core never names a module id.
*
* Adding a member here is a **minor** MODULE_API bump. Requesting one that is not
* here THROWS at the declaration, deliberately: unlike an unfilled slot, an
* unknown contribution is always a typo or a version skew — core's list is fixed
* at build time and a module's `coreApi` range has already been checked — and the
* failure it would otherwise produce is a page that renders empty forever.
*/
export const CORE_CONTRIBUTIONS = Object.freeze({
/** The Team activity feed. Core's because only core can resolve the public/members split on it. */
'team.activity': true,
/** The Team forum panel. Core's because membership and manual grants are core's rules. */
'team.forum': true,
/** The per-Team notification control. Core's because it resolves whether the viewer is in the Team. */
'team.notify': true,
})
/**
* The INVERTED direction: a MODULE declares a slot and CORE fills it.
*
* Added for Teams (TEAMS.md Part 3). The original direction assumes core owns
* the page and a module contributes to it, which is right for the footer and the
* admin user detail. Teams is the other shape: **Teams is a contract primitive,
* not a surface.** Core owns the tables, the sync, the access rules and the
* activity feed; it does not own the vocabulary — a UO shard calls them guilds
* and the next game will call them something else — so the PAGE is the module's
* and the content core contributes to it is core's.
*
* Without this, core would have to publish a `/teams` page under a word it
* invented, next to the module's own Guilds page saying the same thing twice.
*
* A module namespaces its slot under its own id (`uo.guild.detail`), which is
* what stops two modules colliding and what makes the owner readable at the fill
* site. The namespace is enforced rather than conventional.
*
* **`options.core` names which of core's contributions belongs in that place.**
* It is optional — a module may declare a slot it fills itself, or one it keeps
* empty for now — and it is the only thing that gets core's content into the
* page. The place name stays the module's own word; the contribution is core's.
*
* **Ordering is why this is a separate call and not just `declareSlot` exposed
* to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
* are deferred and injected after core's), so at the moment core would like to
* fill one of these, it does not exist yet. Core therefore offers its
* contributions through `offerCoreFill` below, applied after every module chunk
* has evaluated — see main.jsx.
*/
export function declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) {
throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
}
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
const contribution = options.core ?? null
if (contribution !== null && !Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
throw new Error(
`declareModuleSlot: "${name}" asks for core contribution "${contribution}", which core does not ` +
`offer. Known: ${Object.keys(CORE_CONTRIBUTIONS).join(', ')}.`,
)
}
slots.set(name, { Component: null, filledBy: null, declaredBy: id, wants: contribution })
}
// Core's pending contributions, applied once every module chunk has evaluated.
// Kept as a list rather than applied eagerly because no module-declared slot
// exists when core offers — see the ordering note above.
const coreFills = []
/**
* Core: "here is my <contribution>, for whichever module asked for it."
*
* Deliberately not an error when nothing asked. A deployment with no game module
* installed asks for none of these, and core offering content for a page that
* does not exist is the ordinary case rather than a misconfiguration — the mirror
* of an unfilled slot rendering nothing.
*
* More than one slot may ask for the same contribution, and each gets it. Core
* has no reason to care how many places a module wants its feed in, and refusing
* the second would be core making a layout decision on a page it does not own.
*/
export function offerCoreFill(contribution, Component) {
if (!Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
throw new Error(`offerCoreFill: "${contribution}" is not in CORE_CONTRIBUTIONS`)
}
if (typeof Component !== 'function') throw new Error(`offerCoreFill: ${contribution} is not a component`)
coreFills.push([contribution, Component])
}
/** Apply core's contributions. Called once from main.jsx, after module chunks have run. */
export function applyCoreFills() {
for (const [contribution, Component] of coreFills) {
for (const entry of slots.values()) {
if (entry.wants !== contribution) continue
if (entry.filledBy) continue // a module already claimed it; first fill wins
entry.Component = Component
entry.filledBy = 'core'
}
}
coreFills.length = 0
}
/**
* 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 `<Slot wrap>` 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
// this ships to, so two modules asking for the same slot keep load order
// which is alphabetical by id, the same order the server scans in (§4.2).
// Sorted by the `order` a module asked for, stable within equal orders so two
// modules registering the same slot stay in load (alphabetical id) order.
export const navFor = (area) =>
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
export const featureProviderFor = (namespace) => providers.get(namespace)
/**
* Every registered provider, for core's feature context to call.
*
* Exported from the module but deliberately NOT a member of the `registry`
* object below: a module asks for a namespace it knows the name of, and has no
* business enumerating what everyone else registered. Core needs the list
* because it has to call each hook — unconditionally, in a fixed order, at the
* top of a component (modules/features.jsx).
*/
export const featureProviders = () =>
[...providers.entries()].map(([namespace, { id, hook }]) => ({ id, namespace, hook }))
export const featureProviderFor = (namespace) => featureProviders.get(namespace)
export const registeredIds = () => [...registered]
/** Test seam. Nothing in the app calls this — there is no unregistering. */
// Test seam.
export function _reset() {
for (const area of AREAS) {
routes[area].length = 0
nav[area].length = 0
}
providers.clear()
coreFills.length = 0
// 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()
featureProviders.clear()
registered.clear()
}
// The object handed to modules on window.__rg.registry. Deliberately the write
// calls plus the read ones: a module reading `routesFor` is how it finds out
// another module is installed, which is the only supported form of module-to-
// module awareness (there is no dependency resolution).
export const registry = {
registerRoutes,
registerNav,
registerFeatureProvider,
registerExtension,
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
declareModuleSlot,
routesFor,
navFor,
featureProviderFor,

View File

@@ -1,31 +1,22 @@
// ── window.__rg — the shared-dependency global ─────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
// docs/website/MODULE_API.md §3.2.
// A module's client half is a PREBUILT ESM chunk (the operator never builds
// anything), served same-origin, and loaded under `script-src 'self'` with no
// 'unsafe-inline'. That combination is what rules out an import map: an import
// map has to be an inline <script type="importmap">, and CSP forbids it
// (MODULE_SYSTEM.md §1.14). So the shared dependencies ride on a global and the
// module's externals resolve against it — docs/website/MODULE_API.md §3.2.
//
// A module's client half is a PREBUILT ESM chunk — the operator never builds
// anything (MODULE_SYSTEM.md §1.14) — served same-origin and loaded under
// `script-src 'self'` with no 'unsafe-inline'. That combination is what rules out
// an import map: an import map has to be an inline `<script type="importmap">`,
// and the policy forbids inline scripts outright. So the shared dependencies ride
// on a global, and the module's Rollup externals are aliased to two-line shims
// that re-export from it (§3.6).
//
// **There is exactly one React in the page and core owns it.** A module that
// bundled its own would get a second hook dispatcher and fail at its first
// useState. That is the same rule the server half enforces for `express` and
// `express-validator` on `ctx`, and for the same reason: anything shared between
// core and a module is owned by core and HANDED OVER, never resolved by the
// module.
// There is exactly ONE React in the page and core owns it. A module that bundled
// its own would get a second hook dispatcher and fail at the first useState.
import * as react from 'react'
import * as reactDom from 'react-dom/client'
import * as router from 'react-router-dom'
// The automatic JSX runtime, and it is not decoration. A module's bundler
// compiles every .jsx file to imports from `react/jsx-runtime` under the modern
// default, and those have to resolve to CORE's React like every other import.
// Without it here a module would have to build with `jsxRuntime: 'classic'`;
// with it, a module uses the default its tooling already assumes.
// The automatic JSX runtime. Without this a module would have to build with
// `jsxRuntime: 'classic'` — its bundler emits `react/jsx-runtime` imports by
// default, and those have to resolve to CORE's React like every other one.
// Exposing it here is what lets a module use the modern default.
import * as jsxRuntime from 'react/jsx-runtime'
import { registry } from './registry.js'
@@ -34,30 +25,15 @@ import { MODULE_API_VERSION } from './version.js'
import PublicLayout from '../components/PublicLayout.jsx'
import PageHeader from '../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
import Slot from './Slot.jsx'
import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError, BASE } from '../api/client.js'
import { request, ApiError } from '../api/client.js'
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
// eight exports — five table rows in §3.4, since `PageState` contributes three —
// are what the smallest UO page already needs beyond React and the router:
// without them a module either reaches into core's tree — violating the
// zero-import rule the whole boundary rests on — or ships its own copies, which
// means a module page that does not look like the site it is installed in, and
// that drifts further every time core's layout changes.
//
// Adding a member is a MINOR MODULE_API_VERSION bump; changing a member's props
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
// the price of the boundary being worth anything.
//
// `AdminPage` was in an early draft of §3.4's table and is deliberately absent:
// core has no such component — admin views are plain markup inside AdminLayout —
// and inventing one to satisfy a table would be a core change with no consumer
// until Phase 3. The contract was amended rather than the code padded (it no
// longer lists it), and adding it later costs a minor bump, which is exactly the
// case the versioning is for.
// The kit is CURATED AND CLOSED, not a re-export of components/ — see §3.4.
// Adding to it is a minor MODULE_API_VERSION bump; changing a member's props is
// a major one. That is a real constraint on core, and it is the price of module
// pages looking like the site they are installed in.
const ui = {
PublicLayout,
PageHeader,
@@ -67,35 +43,14 @@ const ui = {
useAsync,
useAuth,
useSite,
// The ninth member, for the INVERTED slot direction (TEAMS.md Part 3). A
// module that declares a slot on its own page needs the same component core
// renders its own with — the error boundary in particular, since the thing
// being contained here is CORE's content failing inside the MODULE's page.
// Shared rather than reimplemented for the reason the whole kit exists: two
// boundaries with different behaviour would be two bugs.
Slot,
}
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
// namespace over `request` and owns the paths it calls, which is right, because
// it owns the routes at the other end.
//
// `BASE` was in §3.5 from the start and missing from this object until slice 3,
// which is when something first needed it. `request` is fetch-only, so an
// EventSource — the shard's live feed is two of them — has to build its own URL,
// and the alternative is a module hardcoding `/api/v1`: an assertion about where
// core mounts its API that core has never promised to keep.
const api = { request, ApiError, BASE }
// The request PRIMITIVE, not the api object: api.atlas and api.shard are module
// bindings that live in core's client today and move out with the module (§3.5).
// A module owns the paths it calls, which is right — it owns the routes at the
// other end.
const api = { request, ApiError }
/**
* Publish `window.__rg`. Called by main.jsx before it renders, and before any
* module chunk evaluates.
*
* Frozen, one level down as well as at the top: the object a module reaches for
* its React is not somewhere a module gets to leave something for the next one.
* Cross-module communication is a thing the contract does not have, and an
* unfrozen global is how a codebase acquires one by accident.
*/
export function publishSharedDependencies() {
window.__rg = Object.freeze({
version: MODULE_API_VERSION,
@@ -107,5 +62,4 @@ export function publishSharedDependencies() {
ui: Object.freeze(ui),
api: Object.freeze(api),
})
return window.__rg
}

View File

@@ -1,77 +1,8 @@
// The client's copy of MODULE_API_VERSION. It must equal the server's
// (server/src/modules/version.js) — the two halves version ONE contract
// (docs/website/MODULE_API.md §1.1), and a module checks whichever half it is
// talking to: `coreApi` against the server's at load time, `window.__rg.version`
// against the client's before it registers anything.
// The client's copy of MODULE_API_VERSION. Must equal the server's
// (server/src/modules/version.js) — they version ONE contract, and a module
// checks whichever half it is talking to.
//
// Duplicated rather than fetched, and that is deliberate. The value has to be on
// `window.__rg` before the first module chunk evaluates, which is earlier than
// any network round trip could answer — a fetched version would mean either an
// await before render or a module reading `undefined`. The cost of the copy is
// 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.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
// Phase 7): a module may register event actions, budget dimensions, leases and
// param option sources. All four are server-side registrations and nothing on
// `window.__rg` changed — but what they produce is met on this half, in the step
// editor: an option source is what turns a param from a text box into a dropdown
// of real values, and a budget's label and unit are what the switchboard's cap
// box says beside its number. This file bumps for the reason at the top: the two
// halves state ONE version, and a module declares one `coreApi` range against
// both.
// 1.9.0 - a module may ship its own message bodies and rules:
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
// and core's seeders write it on the boot path - but the bodies it ships are
// edited through the template editor this half already renders, and an operator
// meets them there. This file bumps for the reason at the top: the two halves
// state ONE version, and a module declares one `coreApi` range against both.
// 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on
// this half changed: a ceiling is declared on the server's `api` and enforced
// there, and the admin screens that render one read the vocabulary from
// `GET /admin/engagement/triggers` rather than holding a copy. This file bumps
// anyway, for the reason at the top - the two halves state ONE version.
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2). Nothing
// on this half changed: every member the version adds is on the server's `api`
// and `ctx` (registerEventTriggers, registerAudiences, ctx.events.emit,
// ctx.inbox.push). This file bumps anyway, for the reason at the top — the two
// halves state ONE version, and a module declares one `coreApi` range against
// both. The web surfaces the engagement system needs (the rules and template
// editors, the in-app inbox) land in Phases 4, 5 and 7 and will add to this half
// then.
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half
// changed yet: the two client additions the version covers are the `team.overview`
// and `team.member.row` slots, and a slot can only be declared by the page that
// hosts it, which lands with the Team pages in phase 3. This file bumps anyway,
// for the reason at the top — the two halves state ONE version, and a module
// declares one `coreApi` range against both.
//
// 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' |
// 'wide') that renders the `shell-… page-body` wrapper core's own pages write by
// hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit
// component's props is major" does not bite — nothing already written changes
// meaning. It exists because the kit's acceptance run proved a module cannot
// discover the wrapper: the class names are theme.css's and appear in no
// contract, so a module page rendered outside the site's column while doing
// everything the kit said (docs/modules/kit-acceptance.md).
// 1.4.0 — a rule, not a member: §2.7 forbids a module opening a connection to a
// game server from the website process (it talks to a sidecar, which owns the
// durable copy). Nothing on window.__rg changed and nothing on the server's ctx
// changed either; this half bumps because the two halves state ONE version.
// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may
// carry an `icon` component (§3.3), core declares a third slot
// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which
// §3.5 always documented and shared.js never published. Additive throughout: a
// module written against 1.2.0 is unaffected. The server half is untouched and
// bumps anyway, for the reason below.
// 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.10.0'
// Duplicated rather than fetched: the value has to be on window.__rg before the
// first module script evaluates, and that is earlier than any network round trip.
// A test asserts the two files agree.
export const MODULE_API_VERSION = '1.0.0'

View File

@@ -2,14 +2,10 @@ import { useEffect, useMemo, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import NotificationBell from '../../components/NotificationBell.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.js'
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
// One shared frame keeps them terse; each item just supplies its path(s).
@@ -44,16 +40,9 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
const IconMail = () => <Icon><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3.5 6.5L12 13l8.5-6.5" /></Icon>
const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1.2" /><circle cx="4" cy="12" r="1.2" /><circle cx="4" cy="18" r="1.2" /></Icon>
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /><circle cx="12" cy="15" r="1.4" /></Icon>
// 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`
@@ -84,70 +73,8 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
// Member-raised reports (TEAMS.md §5.6). Here rather than under Teams
// because a staffer working a queue should have one place to work — and
// because the queue is deliberately generic, so the next thing that can
// be reported arrives as a row rather than as another nav entry.
{ to: '/admin/moderation/reports', label: 'Reports', icon: IconShield, roles: ['admin', 'moderator'] },
// Moderation rather than System: the screen's daily job is the
// reserved-name review queue, which is moderator work. The three actions
// that publish a game-written name are gated to admins server-side, so a
// moderator reaching this screen is correct — what they do here is file a
// request (TEAMS.md §2.9).
{ to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
],
},
{
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
// Settings. Settings is already one long page of sections, and these six
// screens are two editors, a catalog and two paged tables, none of which is
// a settings section. Email Delivery stays under Settings: configuring a
// transport is not the same job as deciding who gets mail.
title: 'Engagement',
items: [
{ to: '/admin/engagement/rules', label: 'Rules', icon: IconMail, roles: ['admin'] },
{ to: '/admin/engagement/audiences', label: 'Audiences', icon: IconList, roles: ['admin'] },
{ to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
{ to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
{ to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
// Beside the Send Log rather than inside it (Phase 9): the log answers
// "did that message go out", and this answers "why is this person not
// getting any" - and it is the only screen that can lift a suppression.
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
// Last in the group because it is the one screen nobody visits weekly, and
// beside Suppressions on purpose: it is where the reader is told that the
// fourth engagement table does NOT expire, which is otherwise a silence
// that reads as an oversight.
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
],
},
{
// Its own top-level group rather than a row under Content, and staff-wide
// rather than admin-only. Both follow EVENTS.md §K: every read here is
// `staff`, and the moderator's entire power over this feature is the run
// console — the thing they open when an event is doing something wrong at
// 2am. Hiding it from them would leave the one role that exists for incident
// response unable to see the incident. The narrower gates live on the
// actions: authoring is admin+editor and publish/start are admin only, both
// enforced server-side and mirrored on the buttons.
title: 'Events',
items: [
{ to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
// Phase 4. The same staff gate as the list beside it: a calendar is a read,
// and the arcs it manages are authoring gated on the buttons rather than
// on the row.
{ to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
// Phase 6, and the one row in this group that is NOT staff-wide. §K puts
// the switchboard in the same row as the world-changing actions it
// governs: what a deployment permits at all is configuration, not a read,
// and the server gates both the GET and the PUT on `admin`.
{ to: '/admin/events/actions', label: 'Actions', icon: IconGear, roles: ['admin'] },
// Phase 14a, and the one row here that is not about running the
// deployment: it is this staff member's OWN attendance, the same screen
// and the same route a player reads at /account/events. It has no `roles`
// because it needs none — every account has a participation history, and
// the server scopes it to the caller.
{ to: '/admin/events/mine', label: 'My participation', icon: IconCalendar },
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
],
},
{
@@ -156,25 +83,20 @@ export const NAV = [
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
// Admin-only, matching the server: every route under /admin/modules
// re-gates to `admin` on top of the group's staff gate, because installing
// a module runs its code in this process.
{ to: '/admin/modules', label: 'Modules', icon: IconModules, roles: ['admin'] },
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
{ 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/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
],
},
{
items: [
// No `end`: `allowedPathsFor` turns an `end` row into an EXACT match, so
// marking this one exact would leave `/admin/notifications/settings`
// outside the allowlist and bounce a staff member off their own
// preferences screen. The row covering its sub-routes is the point.
{ to: '/admin/notifications', label: 'Notifications', icon: IconBell },
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
@@ -182,6 +104,10 @@ export const NAV = [
const COLLAPSE_KEY = 'admin.nav.collapsed'
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
// The one row an override may never hide: the nav editor itself, which is the
// only screen that can un-hide anything. The write path already refuses it
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
@@ -197,11 +123,15 @@ function keepEditorReachable(overrides) {
return { ...overrides, [UNHIDEABLE]: rest }
}
// Who may see a sidebar row, and where that lets them go, both derived from the
// row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
// lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
// because Admin -> Navigation has always imported it from here.
export { navItemVisibleTo }
// Who may see a sidebar row. The single authority for that question: the layout
// applies it after the override merge (overrides are presentation, this is the
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an
// admin is never offered a row they cannot themselves see (§8.1).
export function navItemVisibleTo(item, role) {
if (item.roles && !item.roles.includes(role)) return false
if (role === 'moderator') return MOD_PATHS.includes(item.to)
return true
}
const TITLES = {
'/admin': 'Dashboard',
@@ -211,56 +141,29 @@ const TITLES = {
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
'/admin/moderation/reports': 'Reports',
'/admin/teams': 'Teams',
'/admin/shard-ops': 'In-Game Ops',
'/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
'/admin/shard': 'Shard (uo-link)',
'/admin/shard-visibility': 'Shard Visibility',
'/admin/shard-atlas': 'Spawn Atlas',
'/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security',
'/admin/notifications': 'Notifications',
'/admin/notifications/settings': 'Notification settings',
'/admin/engagement/rules': 'Engagement Rules',
'/admin/engagement/audiences': 'Engagement Audiences',
'/admin/engagement/templates': 'Message Templates',
'/admin/engagement/triggers': 'Triggers',
'/admin/engagement/suppressions': 'Suppressions',
'/admin/engagement/sends': 'Send Log',
'/admin/engagement/retention': 'Retention',
'/admin/events': 'Events',
'/admin/events/calendar': 'Event calendar',
'/admin/events/actions': 'Event actions',
'/admin/events/mine': 'My participation',
'/admin/events/new': 'New event',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
// not know what they are called. Their nav row does, so the row is the title:
// the longest matching module row wins, so a detail page under a section titles
// as that section rather than falling through to a bare "Admin". Restricted to
// rows a module registered, which is what keeps every core path resolving
// through TITLES and sectionTitle exactly as it does today.
function moduleTitle(baseNav, pathname) {
return baseNav
.flatMap((g) => g.items)
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/characters')) return 'My Characters'
if (pathname.startsWith('/admin/users/')) return 'User'
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
// /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both
// belong to the same section as far as the page title is concerned.
if (pathname.startsWith('/admin/events/runs/')) return 'Event run'
if (pathname.startsWith('/admin/events/')) return 'Event'
return 'Admin'
}
@@ -283,15 +186,7 @@ export default function AdminLayout() {
const navOverrides = useNavOverrides()
const navigate = useNavigate()
const location = useLocation()
const isVisible = useFeatureGate()
// Core's rows plus every installed module's, before the override merge sees
// them — so a module row is editable in Admin -> Navigation like any other
// (modules/nav.js). Computed once: the registry is fixed before the first
// render and nothing unregisters.
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
const title =
TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || sectionTitle(location.pathname)
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
// 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)'
@@ -305,17 +200,11 @@ export default function AdminLayout() {
// NAV itself and this is exactly the code that ran before the feature.
const navGroups = useMemo(
() =>
applyNavOverrides(baseNav, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({
...g,
// `isVisible` is a no-op for every core row — none carries a `feature`
// — and is applied here so that a module row which does carry one is
// gated on the sidebar rather than silently advertised.
items: g.items.filter((item) => navItemVisibleTo(item, user?.role) && isVisible(item)),
}))
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) }))
// Drop any now-empty group so an empty category header never renders.
.filter((g) => g.items.length > 0),
[baseNav, navOverrides.nav_admin, user?.role, isVisible],
[navOverrides.nav_admin, user?.role],
)
// Accordion: track which titled categories are collapsed. Persist across
@@ -342,22 +231,17 @@ export default function AdminLayout() {
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
)?.title
// Where a moderator may go, from the same `roles` that decide what they see.
// It used to be a third hardcoded list — a prefix check over three paths —
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
// was on the sidebar and not in the redirect, so a moderator who clicked
// Houses in their own nav was bounced straight back to Moderation. One
// derivation cannot disagree with itself, which is the point of deriving it.
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
// Confine a moderator who deep-links (or is redirected to the index) to a page
// outside their remit — the API would 403 anyway, so send them to their home.
useEffect(() => {
if (!isModerator) return
if (!isAllowedPath(location.pathname, allowed)) {
const p = location.pathname
const allowed =
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
if (!allowed) {
navigate('/admin/moderation', { replace: true })
}
}, [isModerator, location.pathname, navigate, allowed])
}, [isModerator, location.pathname, navigate])
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
useEffect(() => {
@@ -502,11 +386,6 @@ export default function AdminLayout() {
{title}
</h1>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}>
{/* Staff have an inbox like anyone else — `/auth/me/notifications`
is role-agnostic — and `RequirePlayer` keeps them out of the
player portal, so without this the one place they spend their
time is the one place the bell is missing. */}
<NotificationBell />
<a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
View site
</a>

View File

@@ -4,7 +4,6 @@ import ProviderIcon from '../../../components/ProviderIcon.jsx'
import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through
@@ -26,7 +25,7 @@ function LinkedAccounts() {
const load = useCallback(async () => {
try {
const [ids, avail] = await Promise.all([
api.myIdentities(),
api.admin.linkedIdentities(),
api.authProviders().catch(() => []),
])
setLinked(ids)
@@ -45,7 +44,7 @@ function LinkedAccounts() {
async function unlink(provider) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
try {
await api.unlinkIdentity(provider)
await api.admin.unlinkIdentity(provider)
await load()
} catch (err) {
setError(err.message || 'Could not unlink.')
@@ -135,7 +134,7 @@ export default function AccountAdmin() {
async function load() {
try {
setAccount(await api.myAccount())
setAccount(await api.admin.getAccount())
} catch {
setError('Could not load your account.')
} finally {
@@ -155,7 +154,7 @@ export default function AccountAdmin() {
setMsg('')
setError('')
try {
setSetup(await api.totpSetup())
setSetup(await api.admin.totpSetup())
setCode('')
} catch (err) {
setError(err.message || 'Could not start setup.')
@@ -169,7 +168,7 @@ export default function AccountAdmin() {
setMsg('')
setError('')
try {
const res = await api.totpEnable(code.trim())
const res = await api.admin.totpEnable(code.trim())
setSetup(null)
setCode('')
setNewCodes(res?.recoveryCodes || null)
@@ -187,7 +186,7 @@ export default function AccountAdmin() {
setMsg('')
setError('')
try {
await api.totpDisable(code.trim())
await api.admin.totpDisable(code.trim())
setCode('')
setMsg('Two-factor authentication has been disabled.')
await load()
@@ -323,10 +322,6 @@ export default function AccountAdmin() {
</>
)}
{/* The self-service address, from the same component the player portal
renders — /auth/me/account is one surface for every role. */}
{account && <EmailAddressPanel account={account} reload={load} />}
<LinkedAccounts />
</section>
)

View File

@@ -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 (
<div style={{ maxWidth: 760 }}>
<p style={{ margin: '0 0 18px' }}>
<Link to="/admin/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
Back to my characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} moderation />}
</div>
)
}

View File

@@ -0,0 +1,18 @@
import CharacterStats from '../../../components/CharacterStats.jsx'
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.
// Sits inside the Admin shell, which supplies the "My Characters" page header;
// stat tiles bring it to parity with the Player Portal's Characters page.
export default function AdminCharacters() {
return (
<section style={{ maxWidth: 760 }}>
<CharacterStats scope={api.admin.shard} />
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
<VendorSales fetchSales={api.admin.shard.sales} />
</section>
)
}

View File

@@ -1,310 +0,0 @@
import { useCallback, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// The member-raised content-report queue (TEAMS.md §5.6).
//
// **This is the only view of this queue, and that is the design.** The gap §5.6
// exists to close has a specific shape: leaders moderate their own Team's forum,
// and a Team's leaders are exactly the people who will not report their own Team.
// A leader-visible queue would route a complaint about a leader back to that
// leader. Org lead, 2026-08-18: reports are **site administration only**. If a
// leader-facing view is ever wanted it is a design decision, not a component.
//
// It sits beside Appeals rather than under Teams because a staffer working a
// queue should have one place to work — and because `target_type` is deliberately
// open-ended, so the next consumer (a wiki page, a news comment) arrives as a new
// row here rather than as a new screen.
//
// **Handling a report is bookkeeping about the REPORT, not moderation of the
// content.** Acting on the content itself is the ordinary forum moderation
// control, or a site-wide sanction against the account. Keeping those separate is
// what stops "report" from becoming a way for any member to hide anything, so
// this screen deliberately offers no hide/delete button of its own.
const STATUS_TABS = [
{ key: 'open_work', label: 'Open work', param: undefined },
{ key: 'open', label: 'Open', param: 'open' },
{ key: 'reviewing', label: 'Reviewing', param: 'reviewing' },
{ key: 'actioned', label: 'Actioned', param: 'actioned' },
{ key: 'dismissed', label: 'Dismissed', param: 'dismissed' },
{ key: 'all', label: 'All', param: 'all' },
]
const STATUS_STYLE = {
open: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
reviewing: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
actioned: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
dismissed: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
}
const STATUS_LABEL = {
open: 'Open', reviewing: 'Reviewing', actioned: 'Actioned', dismissed: 'Dismissed',
}
const REASON_LABEL = {
spam: 'Spam',
abuse: 'Abuse',
sexual: 'Sexual',
illegal: 'Illegal',
impersonation: 'Impersonation',
other: 'Other',
}
const bytes = (n) => {
if (!n && n !== 0) return ''
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
/**
* What was reported, rendered from the row the queue already resolved.
*
* Nothing here fetches: §5.6's fourth rule is that a staffer sees uploader, size
* and sniffed type without hunting, and the server attaches all of it in three
* batched reads. A `null` target is a target that has since been hard-deleted,
* and the row still shows — "somebody reported this and by the time we looked it
* was gone" is a fact worth seeing, and dropping it would hide the pattern of a
* member deleting their own content the moment it is reported.
*/
function TargetCell({ report }) {
const t = report.target
if (!t) {
return (
<span style={{ color: 'var(--muted)' }}>
{report.targetType.replace('team_forum_', '')} #{report.targetId} no longer exists
</span>
)
}
if (t.kind === 'upload') {
return (
<span>
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{t.deleted && ' · removed'}
</span>
</span>
)
}
if (t.kind === 'thread') {
return (
<span>
<strong>{t.title}</strong>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.type} by {t.author || 'unknown'}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
return (
<span>
{t.excerpt || <em className="dim">(no text)</em>}
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.author || 'unknown'} in {t.threadTitle}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
export default function ContentReports() {
const [tab, setTab] = useState('open_work')
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const [handling, setHandling] = useState(null)
const [notice, setNotice] = useState(null)
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
const { loading, error, data } = useAsync(
() => api.admin.contentReports({ status: activeTab.param }),
[tab, tick],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load reports." />
const rows = data?.reports || []
return (
<section>
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
Reports raised by members about Team forum content. They come to site staff and are not visible
to a Team&rsquo;s own leaders a leader moderates their own forum, so a report about a leader
has to reach someone above them. Handling a report records a decision about the report; hiding
or removing the content itself is done from the forum, or as a sanction against the account.
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
{STATUS_TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="pill"
style={tab === t.key ? activePill : undefined}
>
{t.label}
</button>
))}
</div>
{notice && (
<p
className="sans"
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
>
{notice.text}
</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Reported content</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Reporter</th>
<th className="adm-th">Age</th>
<th className="adm-th">Status</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={7} style={muted}>
No reports match this filter.
</td>
</tr>
)}
{rows.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 340 }}>
<TargetCell report={r} />
</td>
<td className="adm-td">
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
</td>
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
<td className="adm-td dim">{r.reporter}</td>
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
<td className="adm-td">
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
{r.handledBy && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
{r.handledBy}
{r.handledNote ? `${r.handledNote}` : ''}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
onClick={() => setHandling(r)}
className="btn btn-primary btn-sq"
style={{ padding: '5px 12px', fontSize: '0.82rem' }}
>
Handle
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{handling && (
<HandleModal
report={handling}
onCancel={() => setHandling(null)}
onDone={() => {
setHandling(null)
setNotice({ text: 'Report updated.', tone: 'ok' })
reload()
}}
onError={(message) => setNotice({ text: message, tone: 'error' })}
/>
)}
</section>
)
}
/**
* Record a decision about a report.
*
* The note is optional and worth writing: every transition is audited, dismissals
* included, and the note is what the next staffer to see a repeat report about the
* same content reads to find out why the last one was closed.
*/
function HandleModal({ report, onCancel, onDone, onError }) {
const [status, setStatus] = useState(report.status === 'open' ? 'reviewing' : 'actioned')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
setBusy(true)
try {
await api.admin.handleContentReport(report.id, { status, note: note || undefined })
onDone()
} catch (err) {
onError(err.message || 'Could not update that report.')
setBusy(false)
}
}
return (
<Modal
title={`Report #${report.id}`}
onClose={onCancel}
footer={(
<>
<button className="pill" onClick={onCancel}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
</>
)}
>
<div style={{ display: 'grid', gap: 12 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
This records a decision about the report. It does not hide, delete or restore the content
do that from the forum itself, or against the account.
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{['reviewing', 'actioned', 'dismissed', 'open'].map((value) => (
<button
key={value}
onClick={() => setStatus(value)}
className="pill"
style={status === value ? activePill : undefined}
>
{STATUS_LABEL[value]}
</button>
))}
</div>
<label>
<span className="field-label">Note (optional)</span>
<textarea
className="textarea"
placeholder="Why this was actioned or dismissed — the next staffer to see a repeat report reads this."
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
rows={4}
style={{ width: '100%' }}
/>
</label>
</div>
</Modal>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const muted = { color: 'var(--muted)' }

View File

@@ -66,34 +66,6 @@ export default function Dashboard() {
return (
<section>
{/* Operator warnings: things that are quietly not working and would
otherwise be discovered by someone not receiving an email. The list is
normally empty, which is why it sits above the fold rather than in a
panel — see ENGAGEMENT.md §1.2a (G22). */}
{(dash.warnings || []).map((w) => (
<div
key={w.code}
className="sans"
style={{
fontSize: '0.86rem',
lineHeight: 1.5,
borderRadius: 10,
padding: '12px 16px',
marginBottom: 18,
border: '1px solid #7a6440',
background: 'rgba(224,176,112,0.08)',
color: '#e0b070',
}}
>
{w.message}
{w.href && (
<>
{' '}
<a href={w.href} style={{ color: '#e0b070', textDecoration: 'underline' }}>Open settings</a>
</>
)}
</div>
))}
<div
style={{
display: 'flex',

View File

@@ -2,20 +2,11 @@ import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// Email delivery panel, rendered as a section on the Settings page. Sending goes
// through a registered mail transport (SMTP today) whose credentials the operator
// types here; they are stored encrypted server-side and are write-only over the
// API — a secret field comes back as "set", never as its value.
//
// **The form is not written here.** The server ships each transport's declared
// `credentialFields` with the config, and this renders them. That is the whole
// point of the declaration (ENGAGEMENT.md §3.1): adding a transport must not mean
// editing this file. So there is no `host`, `port` or `password` anywhere below —
// only field kinds.
//
// The "Connect Gmail" button, its redirect banner and its six error strings went
// with the OAuth2 flow (§1.2a). Gmail is still reachable, as an ordinary SMTP
// relay with an app password — which the operator types in like any other host.
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
// captures a refresh token server-side — the token is write-only over the API
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
// requires the Google provider to be configured on the Authentication page first.
const STATUS_COLOR = {
connected: '#7fd0a4',
@@ -23,6 +14,17 @@ const STATUS_COLOR = {
unconfigured: 'var(--muted)',
}
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
const ERROR_TEXT = {
denied: 'Google sign-in was cancelled or denied.',
bad_state: 'The connect session expired. Please try again.',
no_client: 'The Google OAuth client is not configured.',
no_refresh_token:
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
no_email: 'Could not read the Gmail address from Google.',
error: 'Could not connect the Gmail account. Please try again.',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
return (
@@ -50,100 +52,60 @@ function StatusPanel({ config }) {
)
}
// One declared credential field. A `secret` already held renders empty with a
// "leave blank to keep" hint, matching the server's patch semantics: an empty
// secret is omitted from the save, not written as a blank.
function CredentialField({ field, value, isSet, onChange }) {
const hint = [field.help, field.kind === 'secret' && isSet ? 'Currently set — leave blank to keep it.' : null]
.filter(Boolean)
.join(' ')
if (field.kind === 'boolean') {
return (
<label className="sans" style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} style={{ marginTop: 3 }} />
<span>
{field.label}
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem' }}>{hint}</span>}
</span>
</label>
)
}
return (
<label style={{ display: 'block' }}>
<span className="field-label">
{field.label}
{field.required ? '' : ' (optional)'}
</span>
<input
type={field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className="input"
autoComplete={field.kind === 'secret' ? 'new-password' : 'off'}
placeholder={field.placeholder || ''}
/>
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>{hint}</span>}
</label>
)
}
export default function EmailDelivery() {
const { siteTitle } = useSite()
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [transport, setTransport] = useState('smtp')
const [senderEmail, setSenderEmail] = useState('')
const [senderName, setSenderName] = useState('')
const [replyTo, setReplyTo] = useState('')
const [credential, setCredential] = useState({})
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState('')
const [msg, setMsg] = useState('')
const [actionError, setActionError] = useState('')
// Seed the credential inputs from the non-secret values the server returned,
// falling back to each field's declared default. Secrets are never seeded —
// the server does not send them and an empty box means "keep what you have".
const seedCredential = useCallback((c, transportId) => {
const def = (c.transports || []).find((t) => t.id === transportId)
const next = {}
for (const f of def?.credentialFields || []) {
if (f.kind === 'secret') continue
next[f.key] = c.credential?.[f.key] ?? (f.default === null ? '' : f.default)
}
return next
}, [])
const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
const load = useCallback(async (seedForm = false) => {
try {
const c = await api.admin.getEmailConfig()
setConfig(c)
if (seedForm) {
setTransport(c.transport || 'smtp')
setSenderEmail(c.senderEmail || '')
setSenderName(c.senderName || '')
setReplyTo(c.replyTo || '')
setEnabled(c.enabled)
setCredential(seedCredential(c, c.transport || 'smtp'))
}
return c
} catch {
setError('Could not load email settings.')
return null
}
}, [seedCredential])
}, [])
// On mount, surface the outcome of a just-completed connect redirect, strip the
// query params so a refresh doesn't replay the banner, then load config.
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.has('email_connected')) {
setBanner({ kind: 'ok', text: 'Gmail account connected.' })
} else if (params.has('email_error')) {
setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' })
}
if (params.has('email_connected') || params.has('email_error')) {
params.delete('email_connected')
params.delete('email_error')
const qs = params.toString()
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
}
load(true)
}, [load])
// Switching transport starts from the new one's declared defaults, because the
// server does the same: a credential blob is never carried across transports.
function changeTransport(id) {
setTransport(id)
setCredential(seedCredential(config, id))
async function connect() {
setBusy('connect')
setActionError('')
try {
const { url } = await api.admin.emailConnectUrl()
window.location.href = url
} catch (err) {
setActionError(err.message || 'Could not start the connect flow.')
setBusy('')
}
}
async function save() {
@@ -151,19 +113,10 @@ export default function EmailDelivery() {
setMsg('')
setActionError('')
try {
const saved = await api.admin.saveEmailConfig({ transport, senderEmail, senderName, replyTo, credential, enabled })
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
setConfig(saved)
setEnabled(saved.enabled)
setCredential(seedCredential(saved, saved.transport))
setMsg('Saved.')
} catch (err) {
// A refused enable comes back with the reverted config attached, so the
// screen shows what is actually stored rather than the state that was
// rejected.
if (err.body?.config) {
setConfig(err.body.config)
setEnabled(err.body.config.enabled)
}
setActionError(err.message || 'Could not save.')
} finally {
setBusy('')
@@ -180,13 +133,12 @@ export default function EmailDelivery() {
await load()
} catch (err) {
setActionError(err.message || 'Could not send the test email.')
await load()
} finally {
setBusy('')
}
}
async function clearCredentials() {
async function disconnect() {
setBusy('disconnect')
setMsg('')
setActionError('')
@@ -194,11 +146,9 @@ export default function EmailDelivery() {
const c = await api.admin.disconnectEmail()
setConfig(c)
setEnabled(false)
setSenderEmail('')
setCredential(seedCredential(c, c.transport))
setMsg('Credentials cleared.')
setMsg('Disconnected.')
} catch (err) {
setActionError(err.message || 'Could not clear the credentials.')
setActionError(err.message || 'Could not disconnect.')
} finally {
setBusy('')
}
@@ -207,118 +157,84 @@ export default function EmailDelivery() {
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
if (!config) return null
const catalog = config.transports || []
const selected = catalog.find((t) => t.id === transport)
const connected = config.hasRefreshToken
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
<div>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
Sends the contact form, invitations, password resets and team
notifications. Contact-form mail is delivered to the
<strong> Contact email</strong> above. Credentials are stored encrypted
and never shown again.
Sends the contact form through Gmail over OAuth2, delivered to the
<strong> Contact email</strong> above. Reuses the Google authentication
client configure that on the Authentication page first.
</p>
</div>
{config.hadLegacyConnection && !config.hasCredential && (
{banner && (
<div
className="sans"
style={{ fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', border: '1px solid #7a6440', color: '#e0b070' }}
style={{
fontSize: '0.85rem',
borderRadius: 8,
padding: '10px 12px',
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
}}
>
This deployment was connected with the old Gmail sign-in, which has been
removed. <strong>No mail is being sent.</strong> Enter SMTP credentials
below to restore it for Gmail, use <code>smtp.gmail.com</code> port 587
with an app password.
{banner.text}
</div>
)}
<StatusPanel config={config} />
{catalog.length > 1 && (
<label style={{ display: 'block' }}>
<span className="field-label">Transport</span>
<select value={transport} onChange={(e) => changeTransport(e.target.value)} className="input">
{catalog.map((t) => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</label>
{!config.googleConfigured && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
The Google authentication provider needs a client ID and secret before
you can connect a Gmail account.
</p>
)}
{selected?.help && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>{selected.help}</p>
)}
{(selected?.credentialFields || []).map((f) => (
<CredentialField
key={f.key}
field={f}
value={credential[f.key]}
isSet={Boolean(config.secretsSet?.[f.key])}
onChange={(v) => setCredential((prev) => ({ ...prev, [f.key]: v }))}
/>
))}
<label style={{ display: 'block' }}>
<span className="field-label">Send from</span>
<input
type="email"
value={senderEmail}
onChange={(e) => setSenderEmail(e.target.value)}
className="input"
autoComplete="off"
placeholder="noreply@example.com"
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
Must be an address this account is allowed to send as, or the relay will
reject it. Use <strong>Send test</strong> to confirm.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">From display name (optional)</span>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="input"
autoComplete="off"
placeholder={siteTitle}
/>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Reply-To (optional)</span>
<input
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
className="input"
autoComplete="off"
placeholder="Leave blank to reply to the sending address"
/>
</label>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enable email sending
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
{busy === 'save' ? 'Saving…' : 'Save changes'}
</button>
<button onClick={sendTest} disabled={busy === 'test' || !config.hasCredential} className="pill">
{busy === 'test' ? 'Sending…' : 'Send test'}
</button>
{config.hasCredential && (
<button onClick={clearCredentials} disabled={busy === 'disconnect'} className="pill">
Clear credentials
{!connected ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
</button>
)}
</div>
</div>
) : (
<>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enable email sending
</label>
<label style={{ display: 'block' }}>
<span className="field-label">From display name (optional)</span>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="input"
autoComplete="off"
placeholder={siteTitle}
/>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
{busy === 'save' ? 'Saving…' : 'Save changes'}
</button>
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
{busy === 'test' ? 'Sending…' : 'Send test'}
</button>
<button onClick={connect} disabled={busy === 'connect'} className="pill">
Reconnect
</button>
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
Disconnect
</button>
</div>
</>
)}
<div style={{ minHeight: 18 }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}

View File

@@ -1,433 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { describeExpression, describeReach, notPlacementError } from '../../../lib/engagementRules.js'
// Admin → Engagement → Audiences (ENGAGEMENT.md §5.1a, Phase 4b).
//
// A module declares named sets of users over its own data — "members of a team",
// "the governors" — and an operator combines them here into a saved audience a
// rule can point at. Core learns no game vocabulary: it knows an id, a label and
// a resolver it may call.
//
// **Composition narrows and never widens**, and that is the whole security
// content of this screen:
//
// • the saved ceiling is DERIVED from the tightest audience in the expression,
// not chosen — including for "any of", where the intuitive answer (the widest
// of the two) is the wrong one. A ceiling says what an expression is allowed
// to reach, not what it will resolve to, so the boolean operator makes no
// difference to it.
// • two ceilings with no ordering between them (staff and owner, say) have no
// answer at all, and the save is refused rather than guessing a side.
// • "none of" is only available inside an "all of" group. On its own it would
// have to mean "everyone except…" — a broadcast built out of one narrow list.
// The composer does not offer it anywhere else, and the server refuses it
// anyway.
//
// The three-level composer here is deliberate: one top-level all-of/any-of, one
// level of groups inside it, and audiences at the leaves. The stored grammar
// allows more nesting; anything deeper is left to the rule that made it and shown
// read-only, the same way the rule editor treats a nested condition.
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
/** A fresh, empty top-level group. */
const blankExpression = () => ({ op: 'and', nodes: [] })
/** Is this tree one the composer can render — a single group of leaves and not-groups? */
function isComposable(node) {
if (!node || typeof node !== 'object') return false
if (!node.op) return true
if (node.op === 'not') return (node.nodes || []).every((n) => n && !n.op)
if (node.op !== 'and' && node.op !== 'or') return false
return (node.nodes || []).every((n) => n && (!n.op || (n.op === 'not' && (n.nodes || []).every((c) => !c.op))))
}
/** The composer edits a top-level group; a bare leaf is lifted into one. */
const toGroup = (expression) =>
!expression ? blankExpression() : expression.op ? expression : { op: 'and', nodes: [expression] }
// ── One leaf: an audience and its declared parameters ──────────────────────
function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate, first }) {
const declared = audiences.find((a) => a.id === node.audienceId)
return (
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<label style={{ flex: '1 1 240px' }}>
{/* The heading belongs to the group, not to every line in it. */}
{first && <span className="field-label">Audience</span>}
<select
className="select"
value={node.audienceId || ''}
onChange={(e) => onChange({ audienceId: e.target.value, params: {} })}
>
<option value="">Choose</option>
{audiences.map((a) => (
<option key={a.id} value={a.id}>{a.label} reaches at most {a.ceiling}</option>
))}
</select>
</label>
{(declared?.params || []).map((p) => (
<label key={p.id} style={{ flex: '0 1 160px' }}>
<span className="field-label">{p.id}{p.required ? ' *' : ''}</span>
<input
className="input"
value={node.params?.[p.id] ?? ''}
onChange={(e) =>
onChange({
...node,
params: {
...node.params,
// `int` params are sent as numbers: the server type-checks each
// declared param, and "3" against an int is a refusal.
[p.id]: p.type === 'int' && e.target.value !== '' ? Number(e.target.value) : e.target.value,
},
})
}
/>
</label>
))}
{canNegate && (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, paddingBottom: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={negated} onChange={onToggleNegate} />
exclude
</label>
)}
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.72rem', marginBottom: 6 }} onClick={onRemove}>
Remove
</button>
</div>
)
}
// ── The composer ───────────────────────────────────────────────────────────
function SegmentEditor({ audiences, segment, onSaved, onCancel }) {
const [name, setName] = useState(segment?.name || '')
const [group, setGroup] = useState(() => toGroup(segment?.expression))
const [errors, setErrors] = useState([])
const [busy, setBusy] = useState(false)
const isNew = !segment
// `not` is only offered under "all of" (§5.1a). Under "any of" the checkbox
// disappears rather than being offered and refused.
const canNegate = group.op === 'and'
function setNodes(nodes) {
setGroup((g) => ({ ...g, nodes }))
}
function addLeaf() {
setNodes([...group.nodes, { audienceId: '', params: {} }])
}
function replaceAt(i, next) {
setNodes(group.nodes.map((n, j) => (i === j ? next : n)))
}
function toggleNegate(i) {
const node = group.nodes[i]
replaceAt(i, node.op === 'not' ? node.nodes[0] : { op: 'not', nodes: [node] })
}
function changeOp(op) {
// Switching to "any of" drops the exclusions rather than sending a tree the
// server will refuse — and says so, because silently keeping them and failing
// at save would be worse than either.
const nodes = op === 'or' ? group.nodes.map((n) => (n.op === 'not' ? n.nodes[0] : n)) : group.nodes
setGroup({ op, nodes })
}
const expression = useMemo(() => {
const nodes = group.nodes.filter((n) => (n.op === 'not' ? n.nodes[0]?.audienceId : n.audienceId))
if (!nodes.length) return null
if (nodes.length === 1 && !nodes[0].op) return nodes[0]
return { op: group.op, nodes }
}, [group])
const localError = expression ? notPlacementError(expression) : null
async function submit(e) {
e.preventDefault()
setErrors([])
if (!expression) return setErrors(['Add at least one audience.'])
if (localError) return setErrors([localError])
setBusy(true)
try {
const body = { name: name.trim(), expression }
if (isNew) await api.admin.createEngagementSegment(body)
else await api.admin.updateEngagementSegment(segment.id, body)
await onSaved()
} catch (err) {
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save that audience.'])
} finally {
setBusy(false)
}
}
return (
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
<div className="field-label" style={{ marginBottom: 14 }}>
{isNew ? 'New saved audience' : `Editing “${segment.name}`}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 280px' }}>
<span className="field-label">Name</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Governors" />
</label>
<label style={{ flex: '0 1 200px' }}>
<span className="field-label">Combine with</span>
<select className="select" value={group.op} onChange={(e) => changeOp(e.target.value)}>
<option value="and">all of these</option>
<option value="or">any of these</option>
</select>
</label>
</div>
<div style={{ marginTop: 18 }}>
{group.nodes.length === 0 && (
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.84rem', color: 'var(--muted)' }}>
No audiences yet. A saved audience is built out of the lists installed modules declare.
</p>
)}
{group.nodes.map((node, i) => {
const negated = node.op === 'not'
const leaf = negated ? node.nodes[0] : node
return (
<LeafRow
key={i}
first={i === 0}
audiences={audiences}
node={leaf}
negated={negated}
canNegate={canNegate}
onToggleNegate={() => toggleNegate(i)}
onChange={(next) => replaceAt(i, negated ? { op: 'not', nodes: [next] } : next)}
onRemove={() => setNodes(group.nodes.filter((_, j) => j !== i))}
/>
)
})}
<button type="button" className="btn btn-sq" onClick={addLeaf} disabled={!audiences.length}>
Add an audience
</button>
{!audiences.length && (
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
No module currently declares any. Install one, or use a plain audience on the rule itself.
</span>
)}
</div>
{canNegate ? (
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
Exclude removes people from what the other rows produced. It is only available under all
of: on its own it would mean everyone except, which is a way to reach the whole
deployment from one narrow list.
</p>
) : (
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
Any of takes the tightest limit of the audiences in it, not the widest combining two
lists never reaches further than the narrower one allows.
</p>
)}
{(errors.length > 0 || localError) && (
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
{(errors.length ? errors : [localError]).map((e) => <li key={e}>{e}</li>)}
</ul>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{busy ? 'Saving…' : isNew ? 'Create' : 'Save changes'}
</button>
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
export default function EngagementAudiences() {
const [audiences, setAudiences] = useState([])
const [segments, setSegments] = useState(null)
const [editing, setEditing] = useState(null) // null | { segment } | { segment: null }
const [error, setError] = useState('')
const [rowError, setRowError] = useState('')
const [reach, setReach] = useState({}) // segment id -> preview
const load = useCallback(async () => {
setError('')
try {
const [declared, saved] = await Promise.all([
api.admin.engagementAudiences(),
api.admin.listEngagementSegments(),
])
setAudiences(declared.audiences || [])
setSegments(saved.segments || [])
} catch {
setError('Could not load audiences.')
}
}, [])
useEffect(() => { load() }, [load])
const audiencesById = useMemo(
() => Object.fromEntries(audiences.map((a) => [a.id, a])),
[audiences],
)
async function preview(segment) {
try {
const counted = await api.admin.previewEngagementReach({ audienceSegmentId: segment.id })
setReach((r) => ({ ...r, [segment.id]: counted }))
} catch (err) {
setReach((r) => ({ ...r, [segment.id]: { count: 0, dormant: true, reason: err.message } }))
}
}
async function remove(segment) {
if (!window.confirm(`Delete “${segment.name}”?`)) return
setRowError('')
try {
await api.admin.deleteEngagementSegment(segment.id)
await load()
} catch (err) {
// A 409 here is the interesting case and the message carries the count:
// deleting a segment a rule still points at would leave that rule reaching
// a different set of people, so it is refused rather than cascaded.
setRowError(err.message || 'Could not delete that audience.')
}
}
if (error) return <ErrorState message={error} />
if (!segments) return <Loading />
if (editing) {
return (
<section>
<SegmentEditor
audiences={audiences}
segment={editing.segment}
onSaved={async () => { setEditing(null); await load() }}
onCancel={() => setEditing(null)}
/>
</section>
)
}
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
Named sets of people a rule can be pointed at, built out of the lists installed modules
declare. A saved audience can only ever narrow combining two lists never reaches further
than the tighter of them allows.
</p>
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ segment: null })}>
New audience
</button>
</div>
{rowError && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Made of</th>
<th className="adm-th">Reaches at most</th>
<th className="adm-th">Right now</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{segments.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No saved audiences yet.
</td>
</tr>
)}
{segments.map((s) => (
<tr key={s.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>
{s.name}
{s.dormant && (
<div>
<span
className="badge"
title={`Not declared right now: ${(s.missingAudiences || []).join(', ')}`}
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
Dormant
</span>
</div>
)}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{describeExpression(s.expression, audiencesById)}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{s.ceiling}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{reach[s.id] ? (
describeReach(reach[s.id])
) : (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => preview(s)}>
Count
</button>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem', marginRight: 6 }}
disabled={!isComposable(s.expression)}
title={isComposable(s.expression) ? undefined : 'Nested more deeply than this composer renders'}
onClick={() => setEditing({ segment: s })}
>
Edit
</button>
<button
type="button"
className="pill"
style={{ ...DANGER, fontSize: '0.72rem' }}
onClick={() => remove(s)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="panel" style={{ padding: 18, marginTop: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>What modules currently declare</div>
{audiences.length === 0 ? (
<p className="sans" style={{ margin: 0, fontSize: '0.84rem', color: 'var(--muted)' }}>
Nothing. Audiences come from installed modules core declares none, because core knows no
game vocabulary.
</p>
) : (
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.84rem', color: 'var(--muted)' }}>
{audiences.map((a) => (
<li key={a.id}>
<span style={{ color: 'var(--text)' }}>{a.label}</span> <code>{a.id}</code>, reaches at
most {a.ceiling}
{(a.params || []).length ? ` (${a.params.map((p) => p.id).join(', ')})` : ''}
</li>
))}
</ul>
)}
</div>
</section>
)
}

View File

@@ -1,230 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Retention (ENGAGEMENT.md Phase 14).
//
// Three of the four engagement tables grew on every fire and nothing had ever
// deleted from any of them. This screen is the policy: how long the deployment
// keeps a cooldown row, a finished outbox row and a send-log entry.
//
// **Why it is a screen, when the other two retention workers in this codebase
// (`team_activity`, `user_notifications`) are invisible settings rows.** The
// send-log horizon changes what an operator-facing page is *able to show* — the
// Send Log is the only answer to "was this person told" — so an operator has to
// be able to see it and set it, not discover it by finding rows missing. Having
// made one visible, hiding the other two would be the worse split: "what does
// this deployment keep" is one question and deserves one answer.
//
// **The fourth table is on this page as prose, not as a control.** Suppressions
// do not expire (org lead, 2026-09-01), and saying so here is the point: an
// operator reading a retention screen that lists three tables would reasonably
// assume the fourth was an oversight.
const FIELDS = [
{
name: 'sends',
label: 'Send log',
table: 'engagement_sends',
// The one horizon the org lead asked to be pickable rather than typed —
// and `custom` stays, because a deployment with a compliance answer to
// give should not be limited to three numbers somebody chose.
presets: [90, 180, 365],
help:
'One row per delivery attempt. This is what Admin → Engagement → Send Log reads, so the '
+ 'horizon is also how far back "was this person told" can be answered. The per-rule hourly '
+ 'ceiling counts this table too, which is why it can never go below a week.',
},
{
name: 'cooldowns',
label: 'Cooldowns',
table: 'engagement_cooldowns',
presets: [7, 30, 90],
help:
'One row per rule, user, subject and channel, written on every fire. Deleting a row that '
+ 'is still in force makes the next fire count as a first fire — that is a duplicate '
+ 'message — so this must stay longer than the longest cooldown on any enabled rule.',
},
{
name: 'outbox',
label: 'Outbox',
table: 'engagement_outbox',
presets: [7, 30, 90],
help:
'Only finished rows are ever removed: sent, failed, cancelled and not-sent. A scheduled '
+ 'row is a message this deployment still intends to send and is never swept, however old '
+ 'the horizon.',
},
]
export default function EngagementRetention() {
const [policy, setPolicy] = useState(null)
const [limits, setLimits] = useState({})
const [warnings, setWarnings] = useState([])
const [longestCooldown, setLongestCooldown] = useState(0)
const [draft, setDraft] = useState({})
const [saving, setSaving] = useState(false)
const [note, setNote] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const apply = useCallback((result) => {
setPolicy(result.retention)
setDraft(result.retention)
setLimits(result.limits || {})
setWarnings(result.warnings || [])
setLongestCooldown(result.longestCooldownSeconds || 0)
}, [])
useEffect(() => {
let alive = true
;(async () => {
try {
const result = await api.admin.getEngagementRetention()
if (alive) apply(result)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [apply])
async function save() {
setSaving(true)
setNote(null)
try {
// The whole draft, not the changed field: this screen is the one place the
// three are set together, and a partial save would leave the warning line
// (which is computed from the cooldown horizon) describing a policy that is
// half saved. The route itself is sparse, so sending three is legal.
const result = await api.admin.setEngagementRetention(draft)
apply(result)
setNote('Saved.')
} catch (err) {
setNote(err.message)
} finally {
setSaving(false)
}
}
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
const dirty = policy && FIELDS.some((f) => Number(draft[f.name]) !== Number(policy[f.name]))
return (
<section>
<p className="sans dim" style={{ fontSize: '0.88rem', maxWidth: 720, marginTop: 0 }}>
How long this deployment keeps the engagement system&rsquo;s own records. A nightly sweep
removes anything older, in batches, and skips a table it cannot read rather than failing
the run.
</p>
{warnings.map((w) => (
<p
key={w}
className="sans"
style={{
fontSize: '0.85rem',
maxWidth: 720,
padding: '10px 12px',
borderLeft: '3px solid #d98b84',
background: 'rgba(217, 139, 132, 0.08)',
}}
>
{w}
</p>
))}
<div style={{ display: 'grid', gap: 22, maxWidth: 720, marginTop: 20 }}>
{FIELDS.map((f) => {
const spec = limits[f.name] || {}
const value = draft[f.name] ?? ''
const isPreset = f.presets.includes(Number(value))
return (
<div key={f.name}>
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
<span className="field-label" style={{ fontWeight: 600 }}>{f.label}</span>
<code className="dim" style={{ fontSize: '0.74rem' }}>{f.table}</code>
</div>
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '4px 0 8px' }}>
{f.help}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label>
<span className="field-label">Keep for</span>
<select
className="select"
value={isPreset ? String(value) : 'custom'}
onChange={(e) => {
const next = e.target.value
// Choosing "custom" must not blank the field — the number
// box below is what the operator is about to edit, and an
// empty one would post NaN.
if (next === 'custom') return
setDraft({ ...draft, [f.name]: Number(next) })
}}
>
{f.presets.map((d) => (
<option key={d} value={String(d)}>{d} days</option>
))}
<option value="custom">Custom</option>
</select>
</label>
<label>
<span className="field-label">Days</span>
<input
className="input"
type="number"
min={spec.min ?? 2}
max={spec.max ?? 3650}
style={{ width: 110 }}
value={value}
onChange={(e) => setDraft({ ...draft, [f.name]: e.target.value === '' ? '' : Number(e.target.value) })}
/>
</label>
{spec.min !== undefined && (
<span className="sans dim" style={{ fontSize: '0.78rem', paddingBottom: 8 }}>
{spec.min}{spec.max} days
</span>
)}
</div>
</div>
)
})}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 24 }}>
<button type="button" className="pill" disabled={!dirty || saving} onClick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
{dirty && (
<button type="button" className="pill" disabled={saving} onClick={() => setDraft(policy)}>
Discard
</button>
)}
{note && <span className="sans" style={{ fontSize: '0.82rem' }}>{note}</span>}
</div>
<div style={{ maxWidth: 720, marginTop: 32 }}>
<h3 className="sans" style={{ fontSize: '0.95rem', marginBottom: 6 }}>
Suppressed addresses do not expire
</h3>
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>
A suppression is a standing decision, not a record of something that happened. Ageing one
out would re-mail an address that already hard-bounced or asked to be left alone, which is
how a sender loses a domain&rsquo;s reputation. The way out of that list stays a
deliberate act:{' '}
<strong>Lift</strong> on the row, in Admin Engagement Suppressions.
</p>
{longestCooldown > 0 && (
<p className="sans dim" style={{ fontSize: '0.84rem', marginBottom: 0 }}>
The longest cooldown on an enabled rule right now is {longestCooldown} seconds.
</p>
)}
</div>
</section>
)
}

View File

@@ -1,716 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import {
formFromRule,
ruleToPayload,
audienceChoicesFor,
segmentChoicesFor,
describeReach,
describeRule,
audienceWarning,
conditionRowsFrom,
conditionsFromRows,
operatorsForType,
} from '../../../lib/engagementRules.js'
// Admin → Engagement → Rules (ENGAGEMENT.md Phase 4b).
//
// A rule is trigger → audience → channels → timing, and this is the screen that
// writes one. Everything it decides lives in lib/engagementRules.js so it can be
// tested; this file renders it and talks to the API.
//
// Four things about this screen are deliberate and would be wrong the obvious
// way round:
//
// 1. **The on/off switch is not the form.** It is its own request against its
// own route, and it does not re-validate the rule. A rule whose module has
// been uninstalled is dormant, is the rule an operator most wants stopped,
// and is exactly the rule the form would refuse to save.
// 2. **A rule's trigger is fixed once it exists.** Its cooldowns, its pending
// outbox rows and its send-log history are all about one trigger id.
// 3. **Every rule arrives off.** §7.1 Q3 makes rules operator-editable data on
// the condition that nothing starts mailing by itself — so a new rule is
// created disabled and switched on afterwards, as a separate act.
// 4. **The reach preview is a number.** Never a list of people: a
// module-declared segment resolves over game data, and this screen is about
// mail scheduling.
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
const BLANK = {
id: null,
triggerId: '',
name: '',
enabled: false,
audience: 'owner',
audienceSegmentId: null,
channels: [],
templateKeys: {},
conditions: null,
cooldownSeconds: 0,
delaySeconds: 0,
cancelOn: [],
maxSendsPerHour: 100,
}
function Dormant({ reasons }) {
return (
<span
className="badge"
title={reasons.join('\n')}
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
Dormant
</span>
)
}
// ── The editor ─────────────────────────────────────────────────────────────
function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
const [form, setForm] = useState(() => (rule ? formFromRule(rule) : { ...BLANK }))
const [conditionState, setConditionState] = useState(() => conditionRowsFrom(rule?.conditions))
const [preview, setPreview] = useState(null)
const [previewing, setPreviewing] = useState(false)
const [errors, setErrors] = useState([])
const [busy, setBusy] = useState(false)
const isNew = !form.id
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
const trigger = useMemo(
() => catalog.triggers.find((t) => t.id === form.triggerId) || null,
[catalog.triggers, form.triggerId],
)
const audienceChoices = audienceChoicesFor(trigger, catalog.ceilings)
const segmentChoices = segmentChoicesFor(trigger, catalog.ceilings, segments)
const variables = trigger?.variables || []
// Changing the trigger invalidates the audience and every condition, because
// both are stated in the old trigger's vocabulary. Clearing them is the honest
// move: keeping a condition on a variable the new trigger never carries would
// make the rule fire on nothing, silently (an absent variable fails every
// comparison, by design).
function pickTrigger(id) {
const next = catalog.triggers.find((t) => t.id === id)
setForm((f) => ({
...f,
triggerId: id,
audience: next?.audience || 'owner',
audienceSegmentId: null,
}))
setConditionState({ op: 'and', rows: [], editable: true })
setPreview(null)
}
function toggleChannel(id) {
setForm((f) => ({
...f,
channels: f.channels.includes(id) ? f.channels.filter((c) => c !== id) : [...f.channels, id],
}))
}
async function runPreview() {
setPreviewing(true)
try {
setPreview(
await api.admin.previewEngagementReach({
audience: form.audience,
audienceSegmentId: form.audienceSegmentId,
triggerId: form.triggerId,
}),
)
} catch (err) {
setPreview({ count: 0, dormant: true, reason: err.message || 'could not be resolved' })
} finally {
setPreviewing(false)
}
}
async function submit(e) {
e.preventDefault()
setErrors([])
setBusy(true)
const payload = ruleToPayload({
...form,
conditions: conditionState.editable
? conditionsFromRows(conditionState.op, conditionState.rows, variables)
: form.conditions,
})
try {
if (isNew) await api.admin.createEngagementRule(payload)
else await api.admin.updateEngagementRule(form.id, payload)
await onSaved()
} catch (err) {
// The server sends every problem, not just the first. A form that shows one
// makes an operator fix four things in four round trips.
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save the rule.'])
} finally {
setBusy(false)
}
}
return (
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
<div className="field-label" style={{ marginBottom: 14 }}>
{isNew ? 'New rule' : `Editing “${rule.name}`}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 280px' }}>
<span className="field-label">Trigger</span>
{isNew ? (
<select className="select" value={form.triggerId} onChange={(e) => pickTrigger(e.target.value)}>
<option value="">Choose an event</option>
{catalog.triggers.map((t) => (
<option key={t.id} value={t.id}>
{t.label} ({t.id})
</option>
))}
</select>
) : (
<input className="input" value={form.triggerId} readOnly disabled />
)}
{!isNew && (
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
A rule keeps its trigger its cooldowns, queued sends and history are all about this one.
</span>
)}
</label>
<label style={{ flex: '1 1 280px' }}>
<span className="field-label">Name</span>
<input
className="input"
value={form.name}
onChange={(e) => set({ name: e.target.value })}
placeholder="IDOC warning to the owner"
/>
</label>
</div>
{trigger?.description && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--muted)' }}>
{trigger.description}
</p>
)}
{/* ── Audience ── */}
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Who it reaches</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Audience</span>
<select
className="select"
value={form.audienceSegmentId ? '' : form.audience}
disabled={Boolean(form.audienceSegmentId) || !audienceChoices.length}
onChange={(e) => { set({ audience: e.target.value, audienceSegmentId: null }); setPreview(null) }}
>
{/* Without a trigger there is no ceiling, so there is nothing this
may legitimately offer — and a select with zero options renders
as a control that is broken rather than as one that is waiting. */}
{!audienceChoices.length && <option value="">Choose a trigger first</option>}
{Boolean(form.audienceSegmentId) && <option value="">Using the saved audience </option>}
{audienceChoices.map((c) => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
</label>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">or a saved audience</span>
<select
className="select"
value={form.audienceSegmentId || ''}
onChange={(e) => {
set({ audienceSegmentId: e.target.value ? Number(e.target.value) : null })
setPreview(null)
}}
>
<option value="">None use the audience on the left</option>
{segmentChoices.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<button type="button" className="btn btn-sq" disabled={previewing || !form.triggerId} onClick={runPreview}>
{previewing ? 'Counting…' : 'Preview reach'}
</button>
</div>
{preview && (
<p
className="sans"
style={{
margin: '10px 0 0',
fontSize: '0.84rem',
color: preview.permitted === false || preview.dormant ? '#d98b84' : 'var(--muted)',
}}
>
{describeReach(preview)}
</p>
)}
{/* The `members`-with-no-saved-audience trap, said before the save rather
than discovered after it. It is the DEFAULT the moment a
members-ceiling trigger is chosen, and the rule it produces saves,
switches on and mails nobody. */}
{!preview && audienceWarning(form) && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--accent)' }}>
{audienceWarning(form)}
</p>
)}
{trigger && audienceChoices.length <= 1 && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
This event only permits {trigger.ceiling}. The audience a rule may use is capped by the
event itself, not by the rule.
</p>
)}
{/* ── Channels ── */}
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>How it is delivered</div>
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
{catalog.channels.map((c) => (
<div key={c.id} style={{ flex: '0 1 260px' }}>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={form.channels.includes(c.id)} onChange={() => toggleChannel(c.id)} />
{c.label}
</label>
{form.channels.includes(c.id) && (
<input
className="input"
style={{ marginTop: 6, width: '100%' }}
placeholder="template key (optional)"
value={form.templateKeys[c.id] || ''}
onChange={(e) => set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })}
/>
)}
</div>
))}
</div>
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
Every channel is opt-in: a rule reaches only the people who turned that channel on for this
event in their own notification settings.
</p>
{/* ── Conditions ── */}
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Only when</div>
{!conditionState.editable ? (
<div>
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--accent)' }}>
This rule has a nested condition this editor does not render. It is left exactly as it is
unless you clear it flattening it here would change which events fire the rule.
</p>
<pre
style={{ background: 'var(--panel-flat)', border: '1px solid var(--line)', borderRadius: 6, padding: 10, fontSize: '0.76rem', overflowX: 'auto' }}
>
{JSON.stringify(form.conditions, null, 2)}
</pre>
<button
type="button"
className="pill"
style={{ ...DANGER, fontSize: '0.72rem' }}
onClick={() => { set({ conditions: null }); setConditionState({ op: 'and', rows: [], editable: true }) }}
>
Clear and start again
</button>
</div>
) : (
<>
{conditionState.rows.length > 1 && (
<label style={{ display: 'block', marginBottom: 8 }}>
<span className="field-label">Match</span>
<select
className="select"
style={{ maxWidth: 220 }}
value={conditionState.op}
onChange={(e) => setConditionState((s) => ({ ...s, op: e.target.value }))}
>
<option value="and">all of these</option>
<option value="or">any of these</option>
</select>
</label>
)}
{conditionState.rows.map((row, i) => {
const type = variables.find((v) => v.name === row.variable)?.type
const ops = operatorsForType(catalog.operators, type)
const takesValue = row.cmp !== 'present' && row.cmp !== 'absent'
const patch = (p) =>
setConditionState((s) => ({
...s,
rows: s.rows.map((r, j) => (i === j ? { ...r, ...p } : r)),
}))
return (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<select
className="select"
style={{ flex: '1 1 160px' }}
value={row.variable}
onChange={(e) => patch({ variable: e.target.value })}
>
<option value="">Variable</option>
{variables.map((v) => (
<option key={v.name} value={v.name}>{v.name}</option>
))}
</select>
<select
className="select"
style={{ flex: '1 1 160px' }}
value={row.cmp}
onChange={(e) => patch({ cmp: e.target.value })}
>
<option value="">Is</option>
{ops.map((o) => (
<option key={o.cmp} value={o.cmp}>{o.label}</option>
))}
</select>
{takesValue && (
<input
className="input"
style={{ flex: '2 1 200px' }}
value={row.value}
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'comma, separated, values' : 'value'}
onChange={(e) => patch({ value: e.target.value })}
/>
)}
<button
type="button"
className="pill"
style={{ ...DANGER, fontSize: '0.72rem' }}
onClick={() => setConditionState((s) => ({ ...s, rows: s.rows.filter((_, j) => j !== i) }))}
>
Remove
</button>
</div>
)
})}
<button
type="button"
className="btn btn-sq"
disabled={!variables.length}
onClick={() =>
setConditionState((s) => ({ ...s, rows: [...s.rows, { variable: '', cmp: '', value: '' }] }))
}
>
Add a condition
</button>
{!variables.length && (
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
Choose a trigger first its declared variables are what a condition can talk about.
</span>
)}
</>
)}
{/* ── Timing and the ceiling ── */}
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Timing</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Wait before sending (seconds)</span>
<input
className="input"
type="number"
min="0"
value={form.delaySeconds}
onChange={(e) => set({ delaySeconds: Number(e.target.value) })}
/>
</label>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">At most once per (seconds)</span>
<input
className="input"
type="number"
min="0"
value={form.cooldownSeconds}
onChange={(e) => set({ cooldownSeconds: Number(e.target.value) })}
/>
</label>
<label style={{ flex: '1 1 160px' }}>
<span className="field-label">Hard cap (sends per hour)</span>
<input
className="input"
type="number"
min="1"
value={form.maxSendsPerHour}
onChange={(e) => set({ maxSendsPerHour: Number(e.target.value) })}
/>
</label>
</div>
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
The cooldown is per recipient and per subject
{trigger?.subjectKey ? ` (“${trigger.subjectKey}”)` : ''} a player whose four houses are all
decaying hears about all four, once each. The hourly cap is per rule and is the hard stop that
keeps a misconfiguration to a bad hour.
</p>
{form.delaySeconds > 0 && (
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Cancel the wait if any of these happen</span>
<select
className="select"
multiple
size={Math.min(5, Math.max(2, catalog.triggers.length))}
value={form.cancelOn}
onChange={(e) => set({ cancelOn: [...e.target.selectedOptions].map((o) => o.value) })}
>
{catalog.triggers.map((t) => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
Only meaningful with a wait there is no window to cancel otherwise, and the save says so.
</span>
</label>
)}
{errors.length > 0 && (
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
{errors.map((e) => <li key={e}>{e}</li>)}
</ul>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{busy ? 'Saving…' : isNew ? 'Create rule (off)' : 'Save changes'}
</button>
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
{isNew && (
<span className="sans" style={{ alignSelf: 'center', fontSize: '0.8rem', color: 'var(--muted)' }}>
A new rule is created switched off. Turn it on from the list when you are happy with it.
</span>
)}
</div>
</form>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
// ── The Phase 6 migration notice ───────────────────────────────────────────
//
// Team notifications used to be sent with no operator configuration at all;
// ENGAGEMENT.md Phase 6 moved them onto rules, and the org lead's decision was to
// seed those rules DISABLED rather than carve an exception into "nothing is on by
// default". The consequence is a deployment whose Team email has stopped and
// nobody has been told — which is G22's failure mode with a different cause — so
// the screen that can fix it says so.
//
// It reads the RULES rather than a flag, so it disappears the moment one is
// switched on and comes back if every one is switched off again. A deployment
// that deleted them all sees nothing, which is right: they made that choice.
//
// **Phase 11 added a second notice of exactly the same shape, for news**
// (ENGAGEMENT.md §7.1 Q9). Publishing a news post used to tickle every subscriber
// directly, and that call is now an emit through the engine, so news push stops
// on upgrade until the seeded `news.post` rule is switched on. Two notices rather
// than one generalised "some rules are off" banner, deliberately: each names a
// capability that USED to work without configuration and now does not, which is
// a different statement from "you have a disabled rule" — and a rule an operator
// created and disabled themselves must never produce a warning.
const TEAM_TRIGGERS = [
'team.forum.post',
'team.announcement',
'team.member.joined',
'team.leadership.changed',
]
const NEWS_TRIGGERS = ['news.post']
// One style for both notices, so the pair reads as one kind of message rather
// than two that happen to look alike.
const NOTICE_STYLE = {
fontSize: '0.85rem',
borderRadius: 8,
padding: '10px 12px',
marginBottom: 16,
border: '1px solid #7a6440',
color: '#e0b070',
}
const triggerOf = (rule) => rule.triggerId || rule.trigger_id
// True only when rules for these triggers EXIST and every one of them is off.
// Zero matching rules means the operator deleted them, which is a choice, not a
// regression to warn about.
function allOff(rules, triggers) {
const group = rules.filter((r) => triggers.includes(triggerOf(r)))
return group.length > 0 && group.every((r) => !r.enabled)
}
const teamRulesAllOff = (rules) => allOff(rules, TEAM_TRIGGERS)
const newsRulesAllOff = (rules) => allOff(rules, NEWS_TRIGGERS)
export default function EngagementRules() {
const [catalog, setCatalog] = useState(null)
const [segments, setSegments] = useState([])
const [rules, setRules] = useState(null)
const [editing, setEditing] = useState(null) // null | { rule } | { rule: null } for new
const [error, setError] = useState('')
const [rowError, setRowError] = useState('')
const load = useCallback(async () => {
setError('')
try {
const [triggers, channels, segs, list] = await Promise.all([
api.admin.engagementTriggers(),
api.admin.engagementChannels(),
api.admin.listEngagementSegments(),
api.admin.listEngagementRules(),
])
setCatalog({
triggers: triggers.triggers || [],
ceilings: triggers.ceilings || [],
operators: triggers.operators || [],
channels: channels.channels || [],
})
setSegments(segs.segments || [])
setRules(list.rules || [])
} catch {
setError('Could not load the engagement rules.')
}
}, [])
useEffect(() => { load() }, [load])
const segmentsById = useMemo(
() => Object.fromEntries(segments.map((s) => [s.id, s])),
[segments],
)
async function toggle(rule) {
setRowError('')
try {
await api.admin.setEngagementRuleEnabled(rule.id, !rule.enabled)
await load()
} catch (err) {
setRowError(err.message || 'Could not change that rule.')
}
}
async function remove(rule) {
if (!window.confirm(`Delete “${rule.name}”? Its queued sends go with it; the send log does not.`)) return
setRowError('')
try {
await api.admin.deleteEngagementRule(rule.id)
await load()
} catch (err) {
setRowError(err.message || 'Could not delete that rule.')
}
}
if (error) return <ErrorState message={error} />
if (!catalog || !rules) return <Loading />
if (editing) {
return (
<section>
<RuleEditor
catalog={catalog}
segments={segments}
rule={editing.rule}
onSaved={async () => { setEditing(null); await load() }}
onCancel={() => setEditing(null)}
/>
</section>
)
}
return (
<section>
{teamRulesAllOff(rules) && (
<div className="sans" style={NOTICE_STYLE}>
<strong>Team notification emails are off.</strong> They used to be sent automatically; they
are now rules, and the four below arrived switched off so that nothing starts mailing on its
own. Switch on the ones this deployment wants per-member preferences and per-Team mutes
still apply above them, and unsubscribe links in mail already sent still work.
</div>
)}
{newsRulesAllOff(rules) && (
<div className="sans" style={NOTICE_STYLE}>
<strong>News notifications are off.</strong> Publishing a news post used to send a push
notification to everyone subscribed to it. That is now the News posts rule below, and it
arrived switched off for the same reason the Team rules did. Switch it on to resume news
push it also carries email and the in-app inbox, each still subject to each persons own
preferences. The in-game town crier and the Discord announcement are unaffected either way.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
A rule turns an event into mail: which event, who hears about it, on which channels, and how
often at most. Nothing sends until a rule is switched on.
</p>
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ rule: null })}>
New rule
</button>
</div>
{rowError && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Rule</th>
<th className="adm-th">Trigger</th>
<th className="adm-th">What it does</th>
<th className="adm-th">State</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rules.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No rules yet. Nothing is being sent.
</td>
</tr>
)}
{rules.map((rule) => (
<tr key={rule.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>{rule.name}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{rule.trigger_id}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{describeRule(rule, { segmentsById })}
</td>
<td className="adm-td">
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={Boolean(rule.enabled)} onChange={() => toggle(rule)} />
{rule.enabled ? 'On' : 'Off'}
</label>
{rule.dormant && (
<div style={{ marginTop: 4 }}><Dormant reasons={rule.dormantReasons || []} /></div>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem', marginRight: 6 }}
onClick={() => setEditing({ rule })}
>
Edit
</button>
<button
type="button"
className="pill"
style={{ ...DANGER, fontSize: '0.72rem' }}
onClick={() => remove(rule)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{rules.some((r) => r.dormant) && (
<p className="sans" style={{ marginTop: 12, fontSize: '0.8rem', color: 'var(--muted)' }}>
A dormant rule names something that is not registered right now usually a module that has
been uninstalled. It is kept exactly as it is, it never fires, and it starts working again
when the module comes back. It can still be switched off.
</p>
)}
</section>
)
}

View File

@@ -1,187 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Send Log (ENGAGEMENT.md §4.5, gap G15, Phase 5b).
//
// G15 was stated as: "no per-message record — no send log, no delivery status, no
// audit". The table has been filling since Phase 4a; this is the screen that reads
// it, and the question it exists to answer is the operator's, not the engine's:
// **did that person get that mail, and if not, why not?**
//
// Two things it deliberately does not show.
//
// • **The address.** The log stores a sha256 so a bounce can be correlated back
// to a recipient (Phase 9) without becoming a second address book. The route
// strips the column; this screen could not render it if it wanted to.
// • **A name for the user.** The `user_id` is what the log holds, and joining
// users in would make a delivery screen into a directory. The id is enough to
// paste into Moderation, which is where a person's record belongs.
//
// `failed` rows are the point of the screen, so the reason is a column and not a
// tooltip: a delivery log whose failures need a hover is a log nobody reads.
const STATUS_LABEL = {
sent: 'Sent',
failed: 'Failed',
suppressed: 'Not sent',
bounced: 'Bounced',
complained: 'Marked as spam',
}
const STATUS_COLOR = {
failed: '#d98b84',
bounced: '#d98b84',
complained: '#d98b84',
}
const PAGE = 50
export default function EngagementSendLog() {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [status, setStatus] = useState('')
const [testTrigger, setTestTrigger] = useState('')
// Phase 14. `total` is now a truncated number, and a screen that shows a total
// without saying so is quietly wrong about the deployment's own history — this
// is the fix for that, and the reason the horizon got an operator-facing
// control rather than the invisible settings row the other two sweeps use.
const [retainDays, setRetainDays] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async (nextOffset, nextStatus) => {
const result = await api.admin.listEngagementSends({
limit: PAGE,
offset: nextOffset,
status: nextStatus || undefined,
})
setRows(result.sends || [])
setTotal(result.total || 0)
setTestTrigger(result.testSendTrigger || '')
// Best-effort and non-blocking: the log is worth showing even if the policy
// cannot be read, so a failure here leaves the note off rather than the
// screen empty.
try {
const policy = await api.admin.getEngagementRetention()
setRetainDays(policy?.retention?.sends ?? null)
} catch {
setRetainDays(null)
}
}, [])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load(offset, status)
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [load, offset, status])
if (loading && rows.length === 0) return <Loading />
if (error) return <ErrorState message={error} />
const to = Math.min(offset + PAGE, total)
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 560 }}>
Every message this deployment tried to deliver, successful or not. Addresses are not kept
here only a one-way hash, so a bounce can be matched back without the log becoming a
second address book.
</p>
<label>
<span className="field-label">Show</span>
<select className="select" value={status} onChange={(e) => { setOffset(0); setStatus(e.target.value) }}>
<option value="">Everything</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
<option value="suppressed">Not sent</option>
<option value="bounced">Bounced</option>
<option value="complained">Marked as spam</option>
</select>
</label>
</div>
{total === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'}
</p>
) : (
<>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">When</th>
<th className="adm-th">What</th>
<th className="adm-th">To</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Result</th>
<th className="adm-th">Detail</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
{new Date(r.created_at).toLocaleString()}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{/* The synthetic test-send id is rendered by name: it is not a
registered trigger and will never appear in the catalog,
so showing the raw id would send someone looking for it. */}
{r.trigger_id === testTrigger
? <span>Test send <span className="dim">from the template editor</span></span>
: <code style={{ fontSize: '0.8rem' }}>{r.trigger_id}</code>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.user_id ? <span className="dim">user #{r.user_id}</span> : <span className="dim"></span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.channel}
{r.transport && <span className="dim"> · {r.transport}</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
{STATUS_LABEL[r.status] || r.status}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{r.detail || ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
{offset + 1}{to} of {total}
{retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Newer
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
Older
</button>
</div>
</div>
</>
)}
</section>
)
}

View File

@@ -1,294 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Suppressions (ENGAGEMENT.md §4.5 gap G16, Phase 9).
//
// **This screen is the only way out of the suppression list**, which is the whole
// reason it exists rather than the list living as a filter on the Send Log. A
// hard bounce is written by a background worker with no human in the loop, so
// without a lift button a mistyped-then-corrected mailbox is silenced for good
// and nobody ever finds out why that person stopped hearing from the deployment.
//
// **Addresses are shown masked, and the mask is deliberate on both ends.** The
// table holds a sha256 and an `address_masked` — `d***@example.com` — and the
// route never returns the hash, for the same reason the Send Log strips it: a
// digest of every address on the deployment, handed to a browser, is an offline
// dictionary attack waiting to be run. The domain survives because the signal an
// operator is actually hunting is domain-shaped ("everything to this company is
// bouncing" is a different problem from three people mistyping their own
// address), and the local part is destroyed rather than shortened so the list can
// never be read back as an address book.
//
// The consequence to keep in mind while reading this file: **lifting a
// suppression needs the WHOLE address typed in**, because the screen genuinely
// does not have it. That is not a rough edge to be smoothed later — it is the
// privacy design working, and the confirm dialog says so.
const REASON_LABEL = {
bounce: 'Hard bounce',
complaint: 'Marked as spam',
manual: 'Added by an admin',
unverified: 'Unverified',
}
const REASON_HELP = {
bounce: 'The receiving server said this mailbox does not exist.',
complaint: 'The recipient reported a message as spam.',
manual: 'Somebody here added it — usually a bounce reported another way.',
unverified: 'Reserved: the verification gate excludes these before a send is queued.',
}
const PAGE = 50
export default function EngagementSuppressions() {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [byReason, setByReason] = useState({})
const [offset, setOffset] = useState(0)
const [reason, setReason] = useState('')
const [search, setSearch] = useState('')
// Debounced separately from `search` so typing a domain does not fire a request
// per keystroke; `search` is what the input shows, `applied` is what was asked.
const [applied, setApplied] = useState('')
const [adding, setAdding] = useState('')
const [note, setNote] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async (nextOffset, nextReason, nextSearch) => {
const result = await api.admin.listEngagementSuppressions({
limit: PAGE,
offset: nextOffset,
reason: nextReason || undefined,
search: nextSearch || undefined,
})
setRows(result.suppressions || [])
setTotal(result.total || 0)
setByReason(result.byReason || {})
}, [])
useEffect(() => {
const t = setTimeout(() => { setOffset(0); setApplied(search.trim()) }, 300)
return () => clearTimeout(t)
}, [search])
const refresh = useCallback(async () => {
setLoading(true)
try {
await load(offset, reason, applied)
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [load, offset, reason, applied])
useEffect(() => { refresh() }, [refresh])
async function addByHand(e) {
e.preventDefault()
const address = adding.trim()
if (!address) return
setNote(null)
try {
const result = await api.admin.suppressAddress(address)
// `created: false` is not a failure — the operator asked for the address to
// be suppressed and it is. Saying so plainly beats an error dialog for an
// outcome that is exactly what was wanted.
setNote(result.created
? `${result.address} will no longer be mailed.`
: `${result.address} was already suppressed.`)
setAdding('')
await refresh()
} catch (err) {
setNote(err.message)
}
}
async function lift() {
// The address cannot come from the row — the screen has only the mask. Asking
// for it in full is the cost of not storing it, and the prompt says why so it
// does not read as a missing feature.
const address = window.prompt(
'Type the full address to let it be mailed again.\n\n'
+ 'Suppressed addresses are stored one-way, so this screen never has the address itself.',
)
if (!address || !address.trim()) return
setNote(null)
try {
await api.admin.unsuppressAddress(address.trim())
setNote(`${address.trim()} can be mailed again.`)
await refresh()
} catch (err) {
setNote(err.message)
}
}
/**
* The per-row Lift (Phase 14). No address is asked for and none is needed: the
* row carries its own `address_hash`, which is the only handle this screen has
* ever been able to have — the address itself is stored one-way.
*
* No confirm dialog, deliberately. Lifting is reversible in one click (the
* Suppress field above is right there), and a browser modal blocks the whole
* tab, which is the failure mode the automation notes in this repo warn about.
*/
async function liftRow(row) {
setNote(null)
try {
await api.admin.unsuppressByHash(row.address_hash, row.channel)
setNote(`${row.address_masked || 'That address'} can be mailed again.`)
await refresh()
} catch (err) {
setNote(err.message)
}
}
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
if (error) return <ErrorState message={error} />
const to = Math.min(offset + PAGE, total)
const summary = Object.entries(byReason).filter(([, n]) => n > 0)
return (
<section>
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
Addresses this deployment has stopped mailing. Engagement rules skip them; password resets,
invites and verification mails still go out, because those are asked for by the person
themselves. Addresses are stored one-way and shown masked.
</p>
{summary.length > 0 && (
<div className="panel-flat" style={{ display: 'flex', gap: 24, flexWrap: 'wrap', padding: '12px 16px', marginBottom: 16 }}>
{summary.map(([r, n]) => (
<div key={r}>
<div className="sans" style={{ fontSize: '1.1rem', fontWeight: 600 }}>{n}</div>
<div className="sans dim" style={{ fontSize: '0.76rem' }} title={REASON_HELP[r] || ''}>
{REASON_LABEL[r] || r}
</div>
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 16 }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Search</span>
<input
className="input"
value={search}
placeholder="a domain, or part of one"
onChange={(e) => setSearch(e.target.value)}
/>
</label>
<label>
<span className="field-label">Reason</span>
<select className="select" value={reason} onChange={(e) => { setOffset(0); setReason(e.target.value) }}>
<option value="">Any</option>
{Object.keys(REASON_LABEL).map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</label>
<form onSubmit={addByHand} style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flex: '1 1 280px' }}>
<label style={{ flex: 1 }}>
<span className="field-label">Suppress an address</span>
<input
className="input"
type="email"
value={adding}
placeholder="someone@example.com"
onChange={(e) => setAdding(e.target.value)}
/>
</label>
<button type="submit" className="pill" style={{ fontSize: '0.74rem' }} disabled={!adding.trim()}>
Suppress
</button>
</form>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} onClick={lift}>
Lift a suppression
</button>
</div>
{note && (
<p className="sans" style={{ fontSize: '0.82rem', margin: '0 0 14px' }}>{note}</p>
)}
{total === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'}
</p>
) : (
<>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Address</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Since</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${r.channel}:${r.address_masked}:${r.created_at}`}>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.address_masked
? <code style={{ fontSize: '0.8rem' }}>{r.address_masked}</code>
: <span className="dim">not recorded</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }} title={REASON_HELP[r.reason] || ''}>
{REASON_LABEL[r.reason] || r.reason}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{r.detail || ''}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{r.channel}</td>
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
{new Date(r.created_at).toLocaleString()}
</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem' }}
disabled={!r.address_hash}
title={r.address_hash
? 'Let this address be mailed again'
: 'This row has no handle to act on'}
onClick={() => liftRow(r)}
>
Lift
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
{offset + 1}{to} of {total}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Newer
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
Older
</button>
</div>
</div>
</>
)}
</section>
)
}

View File

@@ -1,649 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { getEmailBlock, listEmailBlocks, newEmailBlock } from '../../../emailBlocks/index.js'
// Admin → Engagement → Templates (ENGAGEMENT.md §4.6.2, Phase 5b).
//
// Phase 5a moved every subject and body out of `mailer.js` into rows. This is the
// screen that lets someone change one, and its whole shape follows from a single
// fact about email:
//
// **the server renders the mail, so the server renders the preview.**
//
// There is no React renderer for an `email.*` block anywhere in this client. The
// preview is HTML the server produced with the same call the send path uses,
// dropped into a sandboxed iframe. That costs a round trip per edit — debounced
// below — and buys the only property that matters on a screen like this: what is
// on screen is what will arrive, not a second implementation's opinion of it.
//
// **The sandbox is a security boundary, not a nicety.** The preview is
// operator-authored HTML. It renders with `sandbox` and no `allow-scripts`, from
// `srcdoc` (an opaque origin), so it can neither run script nor reach this page's
// cookies even if someone stores markup that gets past `sanitizeHtml`. The
// attributes are asserted in `client/test/emailTemplates.test.js` for the same
// reason the server's checks are asserted: this is the kind of attribute someone
// removes while debugging and does not put back.
//
// What the operator can do here is deliberately bounded (settled with the org
// lead at the start of the phase):
//
// • **A shipped default is edited in place.** `protected` blocks deletion and
// nothing else; saving sets `customized = 1`, which is what stops the next
// seed bump from taking the edit back.
// • **Duplicate is the only way to a new template**, so every template on a
// deployment descends from one that renders.
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
// Three widths, because a mail body has to survive all of them and the failures
// are different: 640 is a desktop client's reading pane, 360 is a phone, and the
// plain-text part is what a text-only client and every screen reader gets.
const WIDTHS = [
['desktop', 'Desktop', 640],
['mobile', 'Mobile', 360],
]
/** Short, human label for a template's channel. */
const CHANNEL_LABEL = { email: 'Email', inapp: 'On the site', push: 'Push' }
// ── The preview frame ──────────────────────────────────────────────────────
/**
* The rendered HTML, in a sandboxed frame.
*
* `dark` applies a CSS inversion to the FRAME, not to the mail: it approximates
* what Apple Mail and Outlook do to a light-only message, which is the failure
* §4.6.2 asks this control to expose ("a light-only template renders as unreadable
* dark-on-dark in about a third of inboxes"). It is an approximation and says so
* on screen — the alternative, rendering a second dark palette server-side, would
* be a preview of a mail this system does not send.
*/
function PreviewFrame({ html, width, dark }) {
return (
<div
style={{
background: dark ? '#1b1b1b' : '#f4f4f5',
padding: 12,
borderRadius: 6,
overflowX: 'auto',
}}
>
<iframe
// No allow-scripts, and no allow-same-origin. Both omissions are load
// bearing; see this file's header.
sandbox=""
srcDoc={html || ''}
title="Message preview"
style={{
width,
maxWidth: '100%',
height: 520,
border: '1px solid var(--rule)',
borderRadius: 4,
background: '#fff',
display: 'block',
margin: '0 auto',
filter: dark ? 'invert(1) hue-rotate(180deg)' : 'none',
}}
/>
</div>
)
}
// ── The editor ─────────────────────────────────────────────────────────────
function TemplateEditor({ template, triggers, onDone, onCancel }) {
const [name, setName] = useState(template.name)
const [subject, setSubject] = useState(template.subject || '')
const [blocks, setBlocks] = useState(template.blocks || [])
const [textBody, setTextBody] = useState(template.text_body || '')
const [status, setStatus] = useState(template.status)
const [triggerId, setTriggerId] = useState(template.trigger_id || '')
const [selected, setSelected] = useState(template.blocks?.[0]?.id || null)
const [preview, setPreview] = useState(null)
const [previewError, setPreviewError] = useState(null)
const [tab, setTab] = useState('html')
const [width, setWidth] = useState('desktop')
const [dark, setDark] = useState(false)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState([])
const [saved, setSaved] = useState(false)
const [testTo, setTestTo] = useState('')
const [testState, setTestState] = useState(null)
// The variable palette. It comes from the server with the row and is refreshed
// by every preview, because re-pointing the template at another trigger changes
// it and the server is the one that knows what that trigger declares.
const [variables, setVariables] = useState(template.variables || [])
const draft = useMemo(
() => ({ name, subject, blocks, textBody: textBody || null, status, triggerId: triggerId || null }),
[name, subject, blocks, textBody, status, triggerId],
)
// Debounced preview. The delay is not about server load — it is one small
// render — but about the frame: re-mounting an iframe on every keystroke makes
// the preview flicker and steals nothing back.
const timer = useRef(null)
useEffect(() => {
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(async () => {
try {
const body = { subject: draft.subject, blocks: draft.blocks, textBody: draft.textBody, triggerId: draft.triggerId }
const result = await api.admin.previewEngagementTemplate(template.id, body)
setPreview(result)
setPreviewError(null)
if (Array.isArray(result.variables)) setVariables(result.variables)
} catch (err) {
// A preview failure is expected while a block is half-edited, so it is
// shown where the preview would be rather than as a page-level error.
setPreviewError(err.body?.errors?.join(' · ') || err.message)
}
}, 400)
return () => timer.current && clearTimeout(timer.current)
}, [draft, template.id])
const selectedBlock = blocks.find((b) => b.id === selected) || null
const selectedDef = selectedBlock ? getEmailBlock(selectedBlock.type) : null
const updateBlock = (id, props) =>
setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, props } : b)))
const addBlock = (type) => {
const block = newEmailBlock(type)
if (!block) return
setBlocks((bs) => [...bs, block])
setSelected(block.id)
}
const move = (id, delta) =>
setBlocks((bs) => {
const i = bs.findIndex((b) => b.id === id)
const j = i + delta
if (i < 0 || j < 0 || j >= bs.length) return bs
const next = [...bs]
;[next[i], next[j]] = [next[j], next[i]]
return next
})
const removeBlock = (id) =>
setBlocks((bs) => {
const next = bs.filter((b) => b.id !== id)
if (selected === id) setSelected(next[0]?.id || null)
return next
})
async function save() {
setSaving(true)
setErrors([])
setSaved(false)
try {
await api.admin.updateEngagementTemplate(template.id, draft)
setSaved(true)
onDone()
} catch (err) {
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
} finally {
setSaving(false)
}
}
async function sendTest() {
setTestState({ busy: true })
try {
const body = { ...draft, to: testTo }
const result = await api.admin.testSendEngagementTemplate(template.id, body)
setTestState({ ok: true, message: `Sent to ${result.to}.` })
} catch (err) {
setTestState({ ok: false, message: err.body?.errors?.join(' · ') || err.message })
}
}
const widthPx = WIDTHS.find(([id]) => id === width)?.[2] || 640
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
<div>
<h2 className="sans" style={{ margin: '0 0 4px', fontSize: '1.05rem' }}>{template.name}</h2>
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
<code>{template.key}</code> · {CHANNEL_LABEL[template.channel] || template.channel}
{template.protected && ' · part of the system'}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-sq" onClick={onCancel}>Back</button>
<button type="button" className="btn btn-primary btn-sq" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
{errors.length > 0 && (
<div className="panel" style={{ padding: 14, marginBottom: 16, borderColor: '#5b2020' }}>
{errors.map((e) => (
<p key={e} className="sans" style={{ margin: '0 0 4px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
))}
</div>
)}
{saved && errors.length === 0 && (
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--muted)' }}>Saved.</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 1fr) minmax(320px, 1.2fr)', gap: 22, alignItems: 'start' }}>
{/* ── Authoring ── */}
<div>
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Name</span>
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
</label>
{template.channel === 'email' && (
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Subject</span>
<input className="input" value={subject} maxLength={300} onChange={(e) => setSubject(e.target.value)} />
<VariableButtons variables={variables} onInsert={(t) => setSubject((s) => s + t)} />
</label>
)}
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Trigger</span>
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
{/* "None" is the right default and not a missing value: every
transactional template is tied to no trigger — mailer renders
it by key with no rule involved. */}
<option value="">None used by key, not by a rule</option>
{triggers.map((t) => (
<option key={t.id} value={t.id}>{t.label} ({t.id})</option>
))}
</select>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
The trigger decides which variables this template may use.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Status</span>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="draft">Draft the shipped default is sent instead</option>
<option value="published">Published this is what goes out</option>
</select>
</label>
</div>
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Body</div>
{blocks.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>No blocks yet. Add one below.</p>
)}
{blocks.map((b, i) => {
const def = getEmailBlock(b.type)
return (
<div
key={b.id}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', marginBottom: 4,
borderRadius: 4, cursor: 'pointer',
background: b.id === selected ? 'var(--panel-2, rgba(255,255,255,0.05))' : 'transparent',
border: `1px solid ${b.id === selected ? 'var(--accent)' : 'transparent'}`,
}}
onClick={() => setSelected(b.id)}
>
<span style={{ width: 18, textAlign: 'center' }}>{def?.icon || '?'}</span>
<span className="sans" style={{ flex: 1, fontSize: '0.86rem' }}>
{/* An unknown type is a client/server version skew, and saying
so beats rendering a blank row the operator cannot act on. */}
{def ? def.label : `${b.type} (not known to this client)`}
</span>
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === 0}
onClick={(e) => { e.stopPropagation(); move(b.id, -1) }}></button>
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === blocks.length - 1}
onClick={(e) => { e.stopPropagation(); move(b.id, 1) }}></button>
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.7rem' }}
onClick={(e) => { e.stopPropagation(); removeBlock(b.id) }}>×</button>
</div>
)
})}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
{listEmailBlocks().map((def) => (
<button key={def.type} type="button" className="pill" title={def.hint}
style={{ fontSize: '0.74rem' }} onClick={() => addBlock(def.type)}>
+ {def.label}
</button>
))}
</div>
</div>
{selectedBlock && selectedDef?.editor && (
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
<div className="field-label" style={{ marginBottom: 10 }}>{selectedDef.label}</div>
<selectedDef.editor
props={selectedBlock.props || {}}
variables={variables}
onChange={(props) => updateBlock(selectedBlock.id, props)}
/>
</div>
)}
<div className="panel" style={{ padding: 18 }}>
<label style={{ display: 'block' }}>
<span className="field-label">Plain-text part (optional override)</span>
<textarea
className="input" rows={5} value={textBody}
placeholder="Leave blank to generate it from the blocks above."
onChange={(e) => setTextBody(e.target.value)}
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.82rem' }}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
Every message has both parts. Writing one here REPLACES the generated text entirely.
</span>
</label>
</div>
</div>
{/* ── Preview ── */}
<div>
<div style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'html' ? 1 : 0.6 }}
onClick={() => setTab('html')}>HTML</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'text' ? 1 : 0.6 }}
onClick={() => setTab('text')}>Plain text</button>
{tab === 'html' && (
<>
<span style={{ width: 10 }} />
{WIDTHS.map(([id, label]) => (
<button key={id} type="button" className="pill"
style={{ fontSize: '0.74rem', opacity: width === id ? 1 : 0.6 }}
onClick={() => setWidth(id)}>{label}</button>
))}
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: dark ? 1 : 0.6 }}
onClick={() => setDark((d) => !d)}>Dark mode</button>
</>
)}
</div>
{previewError ? (
<div className="panel" style={{ padding: 16, borderColor: '#5b2020' }}>
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{previewError}</p>
</div>
) : !preview ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Rendering</p>
) : tab === 'html' ? (
<>
{template.channel === 'email' && (
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
<span className="dim">Subject: </span>{preview.subject || <em className="dim">none</em>}
</p>
)}
<PreviewFrame html={preview.html} width={widthPx} dark={dark} />
{dark && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 6 }}>
An approximation of how a client that inverts a light-only message will show it.
</p>
)}
</>
) : (
<pre className="panel" style={{ padding: 16, fontSize: '0.82rem', whiteSpace: 'pre-wrap', margin: 0 }}>
{preview.text || '(empty — a published template is refused with no text part)'}
</pre>
)}
{preview?.missing?.length > 0 && (
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
No example value for: {preview.missing.join(', ')} these render as nothing here and
will carry real values when the message is actually sent.
</p>
)}
<div className="panel" style={{ padding: 18, marginTop: 18 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Send a test</div>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
Sends what is on screen, saved or not, through the configured transport.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<input className="input" type="email" placeholder="you@example.com" value={testTo}
onChange={(e) => setTestTo(e.target.value)} style={{ flex: 1 }} />
<button type="button" className="btn btn-sq" onClick={sendTest} disabled={testState?.busy}>
{testState?.busy ? 'Sending…' : 'Send'}
</button>
</div>
{testState && !testState.busy && (
<p className="sans" style={{ margin: '8px 0 0', fontSize: '0.82rem', color: testState.ok ? 'var(--muted)' : '#d98b84' }}>
{testState.message}
</p>
)}
</div>
</div>
</div>
</section>
)
}
/** The variable tokens, for the two fields that are not block props. */
function VariableButtons({ variables, onInsert }) {
if (!variables?.length) return null
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{variables.map((v) => (
<button key={v.name} type="button" className="btn btn-ghost btn-xs"
title={`${v.type || 'string'}${v.description ? `${v.description}` : ''}`}
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
onClick={() => onInsert(`{{${v.name}}}`)}>
{v.name}
</button>
))}
</div>
)
}
// ── Duplicate ──────────────────────────────────────────────────────────────
function DuplicateForm({ source, triggers, onDone, onCancel }) {
const [key, setKey] = useState('')
const [name, setName] = useState(`${source.name} (copy)`)
const [triggerId, setTriggerId] = useState(source.trigger_id || '')
const [errors, setErrors] = useState([])
async function submit(e) {
e.preventDefault()
setErrors([])
try {
const { template } = await api.admin.duplicateEngagementTemplate(source.id, { key, name, triggerId: triggerId || null })
onDone(template)
} catch (err) {
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
}
}
return (
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.98rem' }}>Duplicate {source.name}</h3>
<p className="sans dim" style={{ margin: '0 0 16px', fontSize: '0.82rem' }}>
The copy starts as a draft, so nothing sends it until you publish it.
</p>
{errors.map((e) => (
<p key={e} className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
))}
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Key</span>
<input className="input" value={key} maxLength={96} placeholder="notify.my-event"
onChange={(e) => setKey(e.target.value)} />
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
How a rule points at this template. Lowercase letters, digits, dots and dashes; it cannot be
changed afterwards.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Name</span>
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Trigger</span>
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
<option value="">None used by key, not by a rule</option>
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} ({t.id})</option>)}
</select>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq">Duplicate</button>
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
// ── The list ───────────────────────────────────────────────────────────────
export default function EngagementTemplates() {
const [templates, setTemplates] = useState([])
const [triggers, setTriggers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [rowError, setRowError] = useState(null)
const [editing, setEditing] = useState(null)
const [duplicating, setDuplicating] = useState(null)
const load = useCallback(async () => {
const [t, tr] = await Promise.all([api.admin.listEngagementTemplates(), api.admin.engagementTriggers()])
setTemplates(t.templates || [])
setTriggers(tr.triggers || [])
}, [])
useEffect(() => {
let alive = true
;(async () => {
try {
await load()
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [load])
async function open(row) {
setRowError(null)
try {
const { template } = await api.admin.getEngagementTemplate(row.id)
setEditing(template)
} catch (err) {
setRowError(err.message)
}
}
async function remove(row) {
if (!window.confirm(`Delete “${row.name}”?`)) return
setRowError(null)
try {
await api.admin.deleteEngagementTemplate(row.id)
await load()
} catch (err) {
setRowError(err.body?.errors?.join(' · ') || err.message)
}
}
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
if (editing) {
return (
<TemplateEditor
template={editing}
triggers={triggers}
onDone={load}
onCancel={async () => { setEditing(null); await load() }}
/>
)
}
return (
<section>
{duplicating && (
<DuplicateForm
source={duplicating}
triggers={triggers}
onCancel={() => setDuplicating(null)}
onDone={async (template) => { setDuplicating(null); await load(); setEditing(template) }}
/>
)}
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
Every message this deployment sends. The shipped ones are editable your edits survive
upgrades and cannot be deleted, because the system breaks without them. To make a new
template, duplicate one that already works.
</p>
{rowError && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Key</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Status</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td className="adm-td">
{t.name}
{t.protected && (
<span className="pill" style={{ marginLeft: 8, fontSize: '0.68rem' }}>system</span>
)}
<Flags template={t} />
</td>
<td className="adm-td"><code style={{ fontSize: '0.8rem' }}>{t.key}</code></td>
<td className="adm-td">{CHANNEL_LABEL[t.channel] || t.channel}</td>
<td className="adm-td">{t.status === 'published' ? 'Published' : 'Draft'}</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
onClick={() => open(t)}>Edit</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
onClick={() => setDuplicating(t)}>Duplicate</button>
<button type="button" className="pill"
style={{ ...DANGER, fontSize: '0.72rem', opacity: t.protected ? 0.4 : 1 }}
disabled={t.protected}
title={t.protected ? 'Part of the system — edit it or duplicate it' : undefined}
onClick={() => remove(t)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
/**
* The three warnings a row can carry. Each is a different fact and they are worded
* as what an operator should DO, not as the flag name: "dormant" and "behind" mean
* nothing to someone who has not read the design document.
*/
function Flags({ template }) {
const notes = []
if (template.dormant) {
notes.push(`No installed module declares ${template.trigger_id} — nothing will send this.`)
}
if (template.triggerBehind) {
notes.push('Its trigger has changed since this was written; check the variables still exist.')
}
if (template.seedBehind) {
notes.push('A newer version of the shipped default exists. Your edits were kept, so it was not applied.')
}
if (!notes.length) return null
return (
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{notes.map((n) => <div key={n}>{n}</div>)}
</div>
)
}

View File

@@ -1,130 +0,0 @@
import { useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Triggers (ENGAGEMENT.md §4.3, Phase 5b).
//
// Read-only, and structurally so: **there is no table behind this screen.** A
// trigger is DECLARED in code by core or by an installed module, so this is
// whatever registered on the current boot. Uninstall a module and its triggers
// stop appearing here; nothing was deleted and nothing needs to be.
//
// It exists because the two things it shows are otherwise invisible and both are
// load-bearing elsewhere:
//
// • **The variables** are the contract a template may reference. When a rule
// mails nothing sensible, "which variables does this event actually carry"
// is the first question, and the answer used to live only in a module's source.
// • **The ceiling** is the security boundary from G24 — the widest audience a
// rule may ever give this trigger. A rule editor that offers a narrower set
// than an operator expects is obeying a number declared here.
const CEILING_NOTE = {
owner: 'only the person the event is about',
members: 'only members of the thing it is about',
subscribers: 'only people who opted in',
staff: 'only staff',
admin: 'only administrators',
authenticated: 'any signed-in account',
everyone: 'anyone',
}
export default function EngagementTriggers() {
const [triggers, setTriggers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let alive = true
;(async () => {
try {
const { triggers: list } = await api.admin.engagementTriggers()
if (alive) setTriggers(list || [])
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
return (
<section>
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
The events a rule can be built on, declared in code by core and by installed modules. This
list is whatever is registered right now it is not stored anywhere, so a module that is
uninstalled simply stops appearing.
</p>
{triggers.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing is registered.</p>
)}
{triggers.map((t) => (
<div className="panel" key={t.id} style={{ padding: 18, marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div>
<h3 className="sans" style={{ margin: '0 0 2px', fontSize: '0.98rem' }}>{t.label}</h3>
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
<code>{t.id}</code> · from {t.owner} · v{t.version}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<div className="field-label" style={{ marginBottom: 2 }}>Can reach at most</div>
<div className="sans" style={{ fontSize: '0.84rem' }}>
{t.ceiling}
<span className="dim"> {CEILING_NOTE[t.ceiling] || 'see the design document'}</span>
</div>
</div>
</div>
{t.description && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--muted)' }}>
{t.description}
</p>
)}
{(t.variables || []).length > 0 && (
<table className="adm-table" style={{ marginTop: 14 }}>
<thead>
<tr>
<th className="adm-th">Variable</th>
<th className="adm-th">Type</th>
<th className="adm-th">Example</th>
<th className="adm-th">What it is</th>
</tr>
</thead>
<tbody>
{t.variables.map((v) => (
<tr key={v.name}>
{/* `nowrap`: without it the "always set" pill wraps between its
two words on a longer variable name, orphaning "set" on a
line of its own and making the row read as two facts. */}
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
<code style={{ fontSize: '0.8rem' }}>{`{{${v.name}}}`}</code>
{v.required && <span className="pill" style={{ marginLeft: 6, fontSize: '0.66rem' }}>always set</span>}
</td>
<td className="adm-td">{v.type}</td>
<td className="adm-td" style={{ maxWidth: 260, overflowWrap: 'anywhere' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
{/* A list variable's example is an array of objects; showing
it as JSON is honest and short, and it is the shape an
item list repeats over. */}
{typeof v.example === 'string' ? v.example : JSON.stringify(v.example)}
</span>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{v.description || ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
))}
</section>
)
}

View File

@@ -1,280 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Events → Actions — the deployment's switchboard (EVENTS.md §K, Phase 6).
//
// **This screen is the whole of the permission model beyond the role.** A module
// declaring `uo.creature.spawn` is code the operator installed; it is not a
// permission they granted. Enablement is the grant, and the cap is how much of
// it — so this is the one screen in the feature where an operator decides what
// the deployment *can do at all*, rather than what it is going to do tonight.
//
// **Nothing above `notify` and `inspect` arrives enabled.** Installing a module
// must never start doing things, which is the posture a seeded engagement rule
// already takes by arriving `enabled = 0`. The line falls between `inspect` and
// `change` (org lead, 2026-09-03): an `inspect` action reads state and writes
// nothing, so a deployment gains no risk by having it on, and `core.wait` — which
// is `inspect` — arriving off would break every published event that waits.
//
// **A row with no stored setting is not "off".** It is "the default for its risk
// class", computed on the server by the same function the runner asks. The screen
// says which it is looking at, because "an admin turned this on" and "this has
// always been on" are different facts and only one of them is a decision.
//
// **Admin only in both directions**, including the read: §K puts the switchboard
// in the same row as the world-changing actions it governs, and knowing exactly
// what a deployment permits is not a staff-wide read.
const RISK_WORD = {
notify: 'Tells people something',
inspect: 'Reads the world',
change: 'Changes the world',
irreversible: 'Changes the world irreversibly',
}
const RISK_COLOR = {
notify: 'var(--muted)',
inspect: 'var(--muted)',
change: '#d9c184',
irreversible: '#d98b84',
}
const REVERSIBLE_WORD = {
none: 'nothing to undo',
self: 'undoes itself',
ledger: 'undone from the ledger at teardown',
override: 'restores a baseline',
}
export default function EventActions() {
const [actions, setActions] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(null)
const [problem, setProblem] = useState(null)
const [notice, setNotice] = useState(null)
// Cap edits are held here until they are saved, keyed `actionId:dimension`.
// A cap is a number somebody types digit by digit, and writing on every
// keystroke would put "3" in the database on the way to "30".
const [drafts, setDrafts] = useState({})
const load = useCallback(async () => {
const data = await api.admin.eventActions()
setActions(data.actions || [])
}, [])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load()
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => {
alive = false
}
}, [load])
/**
* Write one action's row.
*
* The whole row goes every time — the switch and every cap — because the route
* takes one action per request and a sparse write would have to decide what an
* omitted cap means. Here it can only mean one thing, so it is sent.
*/
const save = async (action, { enabled = action.enabled, caps } = {}) => {
setBusy(action.id)
setProblem(null)
setNotice(null)
const nextCaps = caps !== undefined ? caps : capsOf(action)
try {
await api.admin.saveEventAction({ actionId: action.id, enabled, caps: nextCaps })
await load()
setDrafts((d) => {
const next = { ...d }
for (const d of action.dimensions) delete next[`${action.id}:${d.id}`]
return next
})
setNotice(`Saved ${action.label}.`)
} catch (err) {
setProblem(err.message)
} finally {
setBusy(null)
}
}
/** The caps this row would save: the drafts on top of what is stored. */
const capsOf = (action) => {
const out = {}
for (const { id: dimension } of action.dimensions) {
const draft = drafts[`${action.id}:${dimension}`]
const value = draft !== undefined ? draft : action.caps[dimension]
if (value === '' || value === undefined || value === null) continue
out[dimension] = Number(value)
}
return out
}
const capValue = (action, dimension) => {
const draft = drafts[`${action.id}:${dimension}`]
if (draft !== undefined) return draft
const stored = action.caps[dimension]
return stored === undefined || stored === null ? '' : String(stored)
}
const dirty = (action) =>
action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined)
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
return (
<div>
<h2 className="sans" style={{ margin: '0 0 4px' }}>Event actions</h2>
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: '62ch' }}>
What this deployment permits an event to do, and how much of it per run. Anything that changes
the world arrives switched off installing a module declares a verb, it does not grant
permission to use it. Caps are copied into a run when the run is created, so moving a switch
never changes what a run already in flight is allowed.
</p>
{problem && (
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #d98b84' }}>
<span className="sans" style={{ fontSize: '0.85rem' }}>{problem}</span>
</div>
)}
{notice && (
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #8fc79a' }}>
<span className="sans" style={{ fontSize: '0.85rem' }}>{notice}</span>
</div>
)}
{actions.length === 0 && (
<div className="panel-flat" style={{ padding: 14 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
No module registers an event action. Core always declares its own three, so an empty list
here means the registry did not load.
</p>
</div>
)}
{actions.map((action) => (
<div
key={action.id}
className="panel-flat"
style={{
padding: 14,
marginBottom: 10,
borderLeft: `3px solid ${action.enabled ? RISK_COLOR[action.risk] || 'var(--rule)' : 'var(--rule)'}`,
opacity: action.enabled ? 1 : 0.75,
}}
>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
<strong className="sans" style={{ fontSize: '0.95rem' }}>{action.label}</strong>
<code className="dim" style={{ fontSize: '0.78rem' }}>{action.id}</code>
</div>
{action.description && (
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.82rem' }}>{action.description}</p>
)}
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.78rem' }}>
<span style={{ color: RISK_COLOR[action.risk] }}>{RISK_WORD[action.risk] || action.risk}</span>
{' · '}
{REVERSIBLE_WORD[action.reversible] || action.reversible}
{/* Which of the two facts this is. A default is not a decision, and
an operator auditing their own deployment needs to see the
difference without reading the risk table in their head. */}
{' · '}
{action.configured
? `set by ${action.updatedBy || 'an administrator'}`
: 'never configured — showing the default for its risk class'}
</p>
</div>
<label className="sans" style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: '0.85rem' }}>
<input
type="checkbox"
checked={action.enabled}
disabled={busy === action.id}
onChange={(e) => save(action, { enabled: e.target.checked })}
/>
Enabled
</label>
</div>
{action.dimensions.length > 0 && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--rule)' }}>
<p className="sans dim" style={{ margin: '0 0 6px', fontSize: '0.78rem' }}>
Per-run caps. Blank is uncapped the run still counts what it spends, nothing bounds
it. Where another enabled action spends the same thing, the tightest cap is the one a
run gets.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{action.dimensions.map((d) => (
<label key={d.id} className="sans" style={{ fontSize: '0.8rem' }}>
{/*
The LABEL, with the unit beside the box — both from the module's
`registerEventBudgets` declaration (Phase 7). Before it, this said
`uo.creatures` over an unlabelled number, which is ambiguous in exactly
the case that matters: 30 of what?
*/}
<span className="dim" style={{ display: 'block', marginBottom: 2 }}>
{d.registered ? d.label : d.id}
</span>
<span style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<input
type="number"
min="0"
step="1"
style={{ width: 110 }}
value={capValue(action, d.id)}
disabled={busy === action.id || !d.registered}
onChange={(e) =>
setDrafts((s) => ({ ...s, [`${action.id}:${d.id}`]: e.target.value }))
}
/>
{d.registered && d.unit && (
<span className="dim" style={{ fontSize: '0.75rem' }}>{d.unit}</span>
)}
</span>
{/*
A dimension nobody declares is SHOWN rather than hidden. The action is
refused when it is saved into a step and again if it is ever dispatched,
so the operator needs to be told which module is incomplete — hiding the
row would make a broken module look like a cheap one.
*/}
{!d.registered && (
<span
className="sans"
style={{ display: 'block', marginTop: 2, fontSize: '0.72rem', color: '#d98b84' }}
>
No module declares this as a budget, so a step using this action is
refused. It cannot be capped until one does.
</span>
)}
</label>
))}
<button
type="button"
className="btn"
disabled={busy === action.id || !dirty(action)}
onClick={() => save(action)}
>
Save caps
</button>
</div>
</div>
)}
</div>
))}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,722 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import {
runStatusWord,
isTerminalRun,
isParked,
runControlsFor,
stepControlsFor,
describeLogLine,
} from '../../../lib/eventAuthoring.js'
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
//
// One run: where it is, what each of its steps did, what a human can still do
// about it, and the diagnostic log underneath. Staff-wide to read; the six
// controls are `admin` + `moderator`, and the server re-checks every one of them
// against the run's live status — this screen predicts, it does not decide.
//
// **It polls rather than streaming.** A run changes on the runner's tick, which
// is a fifteen-second clock, and a console watched for the length of an event is
// a tab left open for two hours: an SSE channel for that is a connection held
// per staff member for a screen that could not use the latency. The poll stops
// the moment the run reaches a terminal status, because a completed run has
// nothing further to say.
//
// **The parked step is the thing this screen exists to make impossible to
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
// stay that way for ever unless somebody presses confirm. It is called out above
// the step list rather than being one row in it.
//
// **Phase 5 gave it a second one of those, and the panel is this phase's real
// deliverable** (§ Observability): a phase whose steps have all finished and
// whose advance condition has not been met is also `running` and also
// healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the
// steps, in the condition builder's own words — and the sentence is the
// SERVER'S. `gates[].where` arrives already rendered, because those labels are
// defined in the condition grammar and a second renderer in the browser would
// be a second opinion about what `gte` reads as.
//
// **Phase 8 gave it a third, and it is the one that outlives the event.** The
// resource ledger is what this run changed in the world and what became of it,
// and its unresolved rows are the reason a `completed` run can still need a
// person — EVENTS.md §L: a run reaches `completed` with `cleanup_status =
// 'incomplete'` rather than being held open, because a tidy `completed` row over
// a shard full of orphaned monsters is the failure that would end this feature's
// credibility on its first bad night. The panel is shown on finished runs for
// exactly that reason, and it is the only panel here whose empty state matters.
const POLL_MS = 5000
const STATUS_COLOR = {
failed: '#d98b84',
missed: '#d98b84',
paused: '#d9c184',
cancelled: 'var(--muted)',
running: '#8fc79a',
completed: '#8fc79a',
}
// The six ledger statuses, in the two groups that matter to a reader: green is
// resolved, amber wants a person. `orphaned` and `drifted` are amber rather than
// red because neither is a fault — one thing vanished, the other was taken by
// somebody with every right to take it — and red is reserved for "this did not
// come back and core kept asking".
const RESOURCE_COLOR = {
reverted: '#8fc79a',
confirmed: '#d9c184',
pending: '#d9c184',
reverting: '#d9c184',
drifted: '#d9c184',
orphaned: '#d9c184',
}
const RESOURCE_WORD = {
pending: 'recorded, unconfirmed',
confirmed: 'still out there',
reverting: 'being given back',
reverted: 'given back',
orphaned: 'gone',
drifted: 'someone else moved it',
}
const STEP_COLOR = {
done: '#8fc79a',
failed: '#d98b84',
refused: '#d9c184',
skipped: 'var(--muted)',
cancelled: 'var(--muted)',
}
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
// How many participants the console renders before it stops and counts the rest.
// A run's participants are people and a busy event has hundreds; this panel is a
// check that the collection worked and that the ranking looks right, not the
// results page — that is Phase 14's, and it is public.
const PARTICIPANTS_SHOWN = 50
/**
* Seconds as an operator reads them — the same vocabulary the spec authors a
* gate in, so "28 min" on this screen and `after: '30m'` in the editor are
* obviously the same kind of thing.
*/
function elapsed(seconds) {
const s = Math.max(0, Number(seconds) || 0)
if (s < 60) return `${s} sec`
if (s < 3600) return `${Math.floor(s / 60)} min`
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
return m ? `${h} hr ${m} min` : `${h} hr`
}
/**
* One phase gate, as the panel draws it.
*
* The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and
* was released by the third boss" is the same question as the live one, asked
* after the fact, and it is the one an operator asks the morning after.
*/
function GateRow({ gate, current }) {
const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184'
return (
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
Phase <strong>{gate.phase}</strong>
{current && !gate.satisfied ? ' has not started' : ''}
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
{gate.stalled && ' — STALLED'}
</div>
<dl className="sans" style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', margin: '6px 0 0', fontSize: '0.8rem' }}>
{gate.kind === 'after' ? (
<>
<dt className="dim">waiting for</dt>
<dd style={{ margin: 0 }}>{elapsed(gate.after)} from the start of the phase</dd>
<dt className="dim">until</dt>
<dd style={{ margin: 0 }}>{when(gate.dueAt)}</dd>
</>
) : (
<>
<dt className="dim">waiting on</dt>
<dd style={{ margin: 0 }}>
<code>{gate.waitingOn}</code>
{gate.where ? <> where <em>{gate.where}</em></> : <span className="dim"> any firing</span>}
</dd>
<dt className="dim">seen so far</dt>
<dd style={{ margin: 0 }}>{gate.seen} of {gate.needed}</dd>
</>
)}
<dt className="dim">since</dt>
<dd style={{ margin: 0 }}>{when(gate.since)} ({elapsed(gate.elapsedSeconds)})</dd>
{gate.kind === 'on' && gate.lastEvent && (
<>
<dt className="dim">last related event</dt>
<dd style={{ margin: 0 }}>
<code>{gate.lastEvent.trigger}</code> at {clock(gate.lastEventAt)}
{' — '}
{/* The near miss is the valuable half: "the boss did spawn, in
Britain" and "no boss has spawned" are different answers and
look identical without this line. */}
{gate.lastEvent.matched ? 'counted' : 'did not count'}
{Object.keys(gate.lastEvent.variables || {}).length > 0 && (
<span className="dim">
{' ('}
{Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
{')'}
</span>
)}
</dd>
</>
)}
</dl>
</div>
)
}
export default function EventRun() {
const { runId } = useParams()
const [run, setRun] = useState(null)
const [steps, setSteps] = useState([])
const [counts, setCounts] = useState({})
const [gates, setGates] = useState([])
// The caps this run was given and what it has spent of them (Phase 6). Copied
// into the run when it was created, so this is what THIS run is allowed rather
// than what the switchboard says today.
const [budget, setBudget] = useState([])
// What this run created or borrowed, and what became of each (Phase 8).
const [resources, setResources] = useState([])
const [unresolved, setUnresolved] = useState(0)
// Who took part, best first (Phase 10). Present whether or not the results
// have been published; `run.resultsPublishedAt` is what says which.
const [participants, setParticipants] = useState([])
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState(null)
const [notes, setNotes] = useState({})
const [reason, setReason] = useState('')
const alive = useRef(true)
const load = useCallback(async () => {
const [detail, log] = await Promise.all([
api.admin.getEventRun(runId),
api.admin.getEventRunLog(runId, 200),
])
if (!alive.current) return
setRun(detail.run)
setSteps(detail.steps || [])
setCounts(detail.counts || {})
setGates(detail.gates || [])
setBudget(detail.budget || [])
setResources(detail.resources || [])
setUnresolved(detail.unresolvedResources || 0)
setParticipants(detail.participants || [])
setLines(log.log || [])
}, [runId])
useEffect(() => {
alive.current = true
;(async () => {
setLoading(true)
try {
await load()
setError(null)
} catch (err) {
if (alive.current) setError(err.message)
} finally {
if (alive.current) setLoading(false)
}
})()
return () => {
alive.current = false
}
}, [load])
// The poll, and its own off switch. A terminal run is not re-read: it cannot
// change, and a console left open on last night's completed event should not
// be a request every five seconds until the tab is closed.
useEffect(() => {
if (!run || isTerminalRun(run.status)) return undefined
const timer = setInterval(() => {
load().catch(() => {})
}, POLL_MS)
return () => clearInterval(timer)
}, [run, load])
/** Every control goes through here: press, reload, and surface a refusal. */
const act = async (fn) => {
setBusy(true)
setProblem(null)
try {
await fn()
await load()
} catch (err) {
// A 409 is the ordinary answer to a button pressed against a run that has
// moved on since the screen was drawn, so it is shown as a sentence rather
// than as an error state — and the reload above has already re-drawn the
// controls as they now stand.
setProblem(err.body?.errors?.[0] || err.message)
await load().catch(() => {})
} finally {
setBusy(false)
}
}
if (loading && !run) return <Loading />
if (error) return <ErrorState message={error} />
if (!run) return <ErrorState message="No such run." />
const controls = runControlsFor(run, gates, steps)
const waiting = gates.find((g) => g.phase === run.currentPhase && !g.satisfied)
const parked = steps.filter(isParked)
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
<div>
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
<Link to={`/admin/events/${run.definitionId}`}>{run.definitionTitle}</Link>{' '}
<span className="dim" style={{ fontWeight: 400 }}>v{run.version}</span>
</h2>
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
Occurrence {when(run.scheduledFor)}
{run.scope ? ` · scope ${run.scope}` : ''}
{run.rehearsal ? ' · rehearsal' : ''}
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>
{runStatusWord(run.status)}
{run.currentPhase && <span className="dim" style={{ fontSize: '0.82rem' }}> · {run.currentPhase}</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem' }}>
{run.health !== 'ok' && <span style={{ color: '#d9c184' }}>{run.health} · </span>}
{summary || 'no steps'}
{!isTerminalRun(run.status) && <span> · refreshing</span>}
</div>
</div>
</div>
{/* Health is not status, which is the whole reason the two are separate
columns — but the sentence has to agree with the status it sits beside.
A degraded RUNNING run is the interesting case: still going, already in
trouble. A degraded PAUSED run is not "still running", and saying so on
the one screen an operator opens to find out what stopped it would be
the console contradicting itself. Found in the browser walk. */}
{run.health === 'degraded' && !isTerminalRun(run.status) && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#d9c184', marginTop: 10 }}>
{run.status === 'paused' ? (
<>
Something in this run failed, and it is waiting for a person. Resuming carries the phase
past the failed step; <em>Retry &amp; resume</em> puts that step back in the queue first.
</>
) : (
<>
Something in this run has already had to be retried. It is still running this is
what &ldquo;degraded&rdquo; means, and the log below says what happened.
</>
)}
</p>
)}
{run.lastError && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{run.lastError}</p>
)}
{problem && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{problem}</p>
)}
{/* ── The run controls ── */}
<div className="panel-flat" style={{ padding: '12px 14px', margin: '14px 0', display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Reason (recorded with your name)</span>
<input className="input" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="optional" />
</label>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.pause}
onClick={() => act(() => api.admin.pauseEventRun(run.id, reason))}>
Pause
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.resume}
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
Resume
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.advance}
onClick={() => act(() => api.admin.advanceEventRun(run.id, reason))}>
Advance phase
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
Cancel run
</button>
{/* The separate, admin-only decision (§L). It is a second button rather
than a checkbox on the first because the two are not variants of one
action: one gives the world back, the other deliberately leaves it
changed. A checkbox next to Cancel is a thing an operator unticks by
accident at two in the morning. The server refuses this to a
moderator, and the refusal arrives as a sentence in `problem`. */}
{controls.cancel && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason, false))}>
Cancel, leave changes up
</button>
)}
</div>
{isTerminalRun(run.status) && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>
This run is over ({runStatusWord(run.status)} at {when(run.endedAt)}). Nothing can change it
a run pins the version it started from so that it can still be explained later.
</p>
)}
{/* ── Why this phase has not started (Phase 5) ──
Above the step list for the same reason the parked cue is: a phase
waiting on a condition is `running` and looks completely healthy, and
the one screen an operator opens to find out why nothing is happening
must say so before they have to read a log. */}
{gates.length > 0 && (
<div
className="panel-flat"
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
</h3>
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
{waiting ? (
<>
Every step of this phase has finished. It advances when the condition below is met
nothing times out, and <em>Advance phase</em> is how a person overrides it.
{waiting.stalled && ' This one has been waiting long enough that the run is marked stalled.'}
</>
) : (
'What each phase of this run waited for, and what released it.'
)}
</p>
{gates.map((gate) => (
<GateRow key={gate.phase} gate={gate} current={gate.phase === run.currentPhase} />
))}
</div>
)}
{/* ── What this run is allowed, and what it has spent ──
A meter rather than a sentence: a cap is two numbers and a name, and
unlike a gate it needs no grammar rendered to be read. It is shown for
every run that has a budget at all, finished ones included — "how much
did last night's invasion actually spawn" is the same question asked
the morning after. */}
{budget.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Caps</h3>
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{budget.map((b) => {
const spent = b.cap === null ? 0 : Math.min(b.consumed / b.cap, 1)
const full = b.cap !== null && b.consumed >= b.cap
return (
<tr key={b.dimension}>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
<code style={{ fontSize: '0.78rem' }}>{b.dimension}</code>
</td>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: full ? '#d9c184' : undefined }}>
{b.cap === null ? `${b.consumed} spent` : `${b.consumed} of ${b.cap}`}
</td>
<td style={{ width: '100%', padding: '3px 0' }}>
{b.cap === null ? (
<span className="dim" style={{ fontSize: '0.78rem' }}>no cap</span>
) : (
<span style={{ display: 'block', height: 6, background: 'var(--rule)', borderRadius: 3 }}>
<span
style={{
display: 'block',
height: 6,
width: `${Math.round(spent * 100)}%`,
background: full ? '#d9c184' : '#8fc79a',
borderRadius: 3,
}}
/>
</span>
)}
</td>
{/* Which switch set the number, so an operator can trace a cap
back to a thing they can change rather than wondering
where 30 came from. */}
<td className="dim" style={{ padding: '3px 0 3px 12px', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
{b.from || ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* ── What this run changed in the world (Phase 8) ──
The WHOLE ledger, reverted rows included: "how much did last night's
invasion actually spawn, and did all of it come back" is one question
with two halves, and a list of only the failures answers neither.
Shown on finished runs for the same reason the caps meter is. */}
{(resources.length > 0 || run.cleanupStatus === 'incomplete') && (
<div
className="panel-flat"
style={{
padding: 14,
marginBottom: 14,
borderLeft: `3px solid ${unresolved > 0 ? '#d9c184' : 'var(--rule)'}`,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
What this run changed
</h3>
{/* The manual retry. Offered only on a terminal run, because a run
still in flight has a ledger that is still growing and reverting a
resource the next step is about to use would be undoing an event
while it is happening. */}
{isTerminalRun(run.status) && unresolved > 0 && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.cleanupEventRun(run.id))}>
Try cleanup again
</button>
)}
</div>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
{unresolved > 0 ? (
<>
{unresolved} of these {unresolved === 1 ? 'is' : 'are'} still unresolved. The runner
gives them back on its own and stops asking after a few tries;{' '}
<em>Try cleanup again</em> clears that count and asks once more.
</>
) : (
'Everything this run created or borrowed has been given back.'
)}
</p>
{resources.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
Nothing named a step changed the world and its answer never arrived, so core kept the
record it wrote beforehand and will ask the module to undo it by key.
</p>
) : (
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{resources.map((r) => (
<tr key={r.id}>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
<code style={{ fontSize: '0.78rem' }}>{r.kind}</code>{' '}
<code className="dim" style={{ fontSize: '0.78rem' }}>{r.ref}</code>
</td>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: RESOURCE_COLOR[r.status] }}>
{RESOURCE_WORD[r.status] || r.status}
</td>
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
{r.module}
{r.leaseUntil ? ` · until ${clock(r.leaseUntil)}` : ''}
{r.revertAttempts > 0 ? ` · ${r.revertAttempts} attempt${r.revertAttempts === 1 ? '' : 's'}` : ''}
</td>
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
{r.lastError || ''}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── Who took part (Phase 10) ──
Shown whenever a module has reported anybody, published or not — and the
difference between the two is the whole point of the line under the
heading. A run whose participants are collected and unranked is a real
state, not an error: an author has not placed a `core.results.publish`
step, or has not run it yet. Saying "not published yet" is what stops
somebody reading this table as the final standings. */}
{participants.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
Who took part
</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
{run.resultsPublishedAt ? (
<>Results published {clock(run.resultsPublishedAt)}. Ranked best first.</>
) : (
<>
{participants.length} recorded, and the results have not been published nothing
outside this page shows them, and nobody has a rank yet. Publishing is a{' '}
<code style={{ fontSize: '0.78rem' }}>core.results.publish</code> step in the event
itself.
</>
)}
</p>
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{participants.slice(0, PARTICIPANTS_SHOWN).map((p) => (
<tr key={p.memberKey}>
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', width: 34, textAlign: 'right' }}>
{p.rank ?? ''}
</td>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
<code style={{ fontSize: '0.78rem' }}>{p.memberKey}</code>
</td>
{/* A participant with no `userId` is not a defect: it is
somebody who turned up without a linked website account,
and the module is the only thing that could have known
otherwise. Saying so beats a blank cell. */}
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
{p.userId ? `account ${p.userId}` : 'no linked account'}
</td>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>{p.score}</td>
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
{clock(p.joinedAt)}
</td>
</tr>
))}
</tbody>
</table>
{participants.length > PARTICIPANTS_SHOWN && (
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.78rem' }}>
and {participants.length - PARTICIPANTS_SHOWN} more.
</p>
)}
</div>
)}
{/* ── Waiting on a person ── */}
{parked.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Waiting on a person</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
Nothing else in this phase runs until each of these is confirmed. There is no timeout
a cue posted on Friday is still waiting on Monday.
</p>
{parked.map((step) => (
<div key={step.id} style={{ marginBottom: 10 }}>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.86rem' }}>
{step.params?.instruction || step.actionId}
{step.params?.assignee && <span className="dim"> for {step.params.assignee}</span>}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">What you did (optional)</span>
<input className="input" value={notes[step.id] || ''}
onChange={(e) => setNotes((n) => ({ ...n, [step.id]: e.target.value }))} />
</label>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.confirmEventStep(run.id, step.id, notes[step.id]))}>
Confirm done
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, notes[step.id]))}>
Skip it
</button>
</div>
</div>
))}
</div>
)}
{/* ── The steps ── */}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Steps</h3>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Phase</th>
<th className="adm-th">#</th>
<th className="adm-th">Action</th>
<th className="adm-th">Status</th>
<th className="adm-th">Attempts</th>
<th className="adm-th">Detail</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{steps.map((step) => {
const c = stepControlsFor(run, step, steps)
return (
<tr key={step.id}>
<td className="adm-td" style={{ fontSize: '0.8rem' }}>
{step.phase}
{step.phase === run.currentPhase && <span className="dim"> ·now</span>}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{step.seq + 1}</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{JSON.stringify(step.params)}
</div>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
{isParked(step) ? <span style={{ color: '#d9c184' }}>waiting</span> : step.status}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{step.attempts}
{step.dueAt && new Date(step.dueAt) > new Date() && (
<div style={{ fontSize: '0.74rem' }}>due {clock(step.dueAt)}</div>
)}
</td>
<td className="adm-td" style={{ fontSize: '0.78rem', maxWidth: 280, overflowWrap: 'anywhere' }}>
{step.lastError || ''}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{c.retry && (
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
onClick={() => act(() => api.admin.retryEventStep(run.id, step.id))}>
Retry &amp; resume
</button>
)}
{c.skip && !isParked(step) && (
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, reason))}>
Skip
</button>
)}
</td>
</tr>
)
})}
{steps.length === 0 && (
<tr><td className="adm-td dim" colSpan={7}>No steps have been materialised yet.</td></tr>
)}
</tbody>
</table>
</div>
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
Steps run strictly in order within a phase, and the phase ends when every one of them has
finished. A failed step is not retried by the runner past its attempt limit resuming a
paused run carries the phase past it, and <em>Retry &amp; resume</em> puts the step the run is
stopped at back in the queue.
</p>
{/* ── The log ── */}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>Log</h3>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
The run&rsquo;s own diagnostic record, newest first this is what answers &ldquo;why didn&rsquo;t phase 3
start?&rdquo; without reading server logs. Who published or started what is recorded separately, in
the activity log.
</p>
<div className="panel-flat">
<table className="adm-table">
<tbody>
{lines.map((line) => (
<tr key={line.id}>
<td className="adm-td dim" style={{ fontSize: '0.76rem', whiteSpace: 'nowrap' }}>{clock(line.at)}</td>
<td className="adm-td dim" style={{ fontSize: '0.76rem' }}>{line.phase || ''}</td>
<td className="adm-td" style={{ fontSize: '0.8rem' }}>{describeLogLine(line)}</td>
</tr>
))}
{lines.length === 0 && <tr><td className="adm-td dim">Nothing logged yet.</td></tr>}
</tbody>
</table>
</div>
</section>
)
}

View File

@@ -1,265 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js'
// Admin → Events (EVENTS.md §I, Phase 3).
//
// Two tables on one screen: the definitions an operator authors, and the runs
// those definitions have produced. They are together rather than on two nav rows
// because the question this screen exists to answer is one question — "what is
// scheduled, and what is happening right now" — and the second half of it is the
// one somebody opens at 8pm on a Friday.
//
// **The waiting badge is the whole reason the run table is here rather than
// buried a click away.** A run parked on a GM cue looks perfectly healthy: it is
// `running`, nothing has failed, and it will stay that way for ever because it
// is waiting for a person who does not know they are being waited for. The count
// comes from the run row itself (`waitingSteps`), so a run needs nobody to open
// it before it can say so.
//
// **The calendar is a separate screen, not a third table here.** It answers
// "when", this one answers "what" — and Phase 4, which built it, also made a
// definition able to carry a recurrence, so the two questions stopped having the
// same answer the moment an occurrence could exist before anybody pressed Start.
const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' }
const STATUS_COLOR = {
failed: '#d98b84',
missed: '#d98b84',
paused: '#d9c184',
cancelled: 'var(--muted)',
running: '#8fc79a',
}
const HEALTH_COLOR = { degraded: '#d9c184', stalled: '#d98b84' }
const when = (value) => (value ? new Date(value).toLocaleString() : '—')
export default function EventsAdmin() {
const { user } = useAuth()
const navigate = useNavigate()
const [events, setEvents] = useState([])
const [runs, setRuns] = useState([])
const [state, setState] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const isAdmin = user?.role === 'admin'
const mayAuthor = isAdmin || user?.role === 'editor'
const load = useCallback(async (nextState) => {
const [defs, runList] = await Promise.all([
api.admin.listEvents(nextState || undefined),
api.admin.listEventRuns({ limit: 50 }),
])
setEvents(defs.events || [])
setRuns(runList.runs || [])
}, [])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load(state)
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => {
alive = false
}
}, [load, state])
// "Start now" is an occurrence whose instant is the present, not a separate
// concept — the same route a scheduled occurrence will use in Phase 4. Admin
// only, deliberately (§N2): starting commits the deployment to everything the
// definition contains, unattended.
const startNow = async (event) => {
setBusy(true)
setNotice(null)
try {
const result = await api.admin.startEventRun(event.id, {})
navigate(`/admin/events/runs/${result.run.id}`)
} catch (err) {
setNotice(err.message)
} finally {
setBusy(false)
}
}
if (loading && !events.length && !runs.length) return <Loading />
if (error) return <ErrorState message={error} />
const live = runs.filter((r) => !isTerminalRun(r.status))
const waiting = live.filter((r) => r.waitingSteps > 0)
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
Scheduled, bounded, audited changes to the live world. A definition is authored as a draft,
published as an immutable version, and every occurrence of it runs against the version it
pinned. A definition can repeat once, weekly, or on the nth weekday of the month, in its
own timezone and the <Link to="/admin/events/calendar">calendar</Link> is where those
occurrences are read.
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<label>
<span className="field-label">Show</span>
<select className="select" value={state} onChange={(e) => setState(e.target.value)}>
<option value="">All definitions</option>
<option value="draft">Drafts</option>
<option value="ready">Ready</option>
<option value="archived">Archived</option>
</select>
</label>
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/calendar">
Calendar
</Link>
{mayAuthor && (
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/new">
New event
</Link>
)}
</div>
</div>
{notice && (
<p className="sans" style={{ fontSize: '0.84rem', color: '#d98b84', marginTop: 0 }}>{notice}</p>
)}
{waiting.length > 0 && (
<div className="panel-flat" style={{ padding: '12px 14px', marginBottom: 16, borderLeft: '3px solid #d9c184' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem' }}>
<strong>{waiting.length === 1 ? 'One run is' : `${waiting.length} runs are`} waiting on a
person.</strong>{' '}
<span className="dim">
A cue holds its phase until somebody confirms it was done in-client nothing else will
move it.
</span>
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
{waiting.map((r) => (
<Link key={r.id} className="pill" style={{ fontSize: '0.74rem' }} to={`/admin/events/runs/${r.id}`}>
{r.definitionTitle} · {r.waitingSteps} waiting
</Link>
))}
</div>
</div>
)}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Definitions</h3>
{events.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{state ? 'Nothing matches that filter.' : 'No events have been authored yet.'}
</p>
) : (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Event</th>
<th className="adm-th">State</th>
<th className="adm-th">Version</th>
<th className="adm-th">Series</th>
<th className="adm-th">Updated</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<td className="adm-td" style={{ fontSize: '0.85rem' }}>
<Link to={`/admin/events/${e.id}`}>{e.title}</Link>
<div className="dim" style={{ fontSize: '0.76rem' }}>{e.slug}</div>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{STATE_WORD[e.state] || e.state}</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{e.currentVersion ? `v${e.currentVersion}` : <span className="dim">unpublished</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{e.seriesName || <span className="dim"></span>}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>{when(e.updatedAt)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
{/* Start is admin only and the button follows the route: an
editor sees the definition and cannot commit the
deployment to running it. */}
{isAdmin && e.state === 'ready' && (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
disabled={busy} onClick={() => startNow(e)}>
Start now
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>
Recent runs
{live.length > 0 && <span className="dim" style={{ fontWeight: 400 }}> · {live.length} in flight</span>}
</h3>
{runs.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing has run yet.</p>
) : (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Occurrence</th>
<th className="adm-th">Event</th>
<th className="adm-th">Status</th>
<th className="adm-th">Phase</th>
<th className="adm-th">Health</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
<Link to={`/admin/events/runs/${r.id}`}>{when(r.scheduledFor)}</Link>
{r.rehearsal && <span className="dim" style={{ fontSize: '0.74rem' }}> · rehearsal</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.definitionTitle} <span className="dim">v{r.version}</span>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
{runStatusWord(r.status)}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.currentPhase || <span className="dim"></span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: HEALTH_COLOR[r.health] || undefined }}>
{r.health === 'ok' ? <span className="dim">ok</span> : r.health}
</td>
<td className="adm-td" style={{ textAlign: 'right', fontSize: '0.78rem' }}>
{r.waitingSteps > 0 && (
<span style={{ color: '#d9c184' }}>
waiting on {r.waitingSteps === 1 ? 'a person' : `${r.waitingSteps} people`}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}

View File

@@ -1,457 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import { runStatusWord, isProjected } from '../../../lib/eventAuthoring.js'
// Admin → Events → Calendar (EVENTS.md §I, Phase 4).
//
// **This screen is the deliverable.** What this feature replaces is a WordPress
// calendar plugin with no series field, no recurrence and no results — so a
// month grid that knows about arcs, repeats and local time is not decoration
// here, it is the point.
//
// **Two kinds of entry, drawn differently on purpose.** A solid one is a *run*:
// a real row with a status, a pinned version and a console, and somebody can
// cancel it. A dashed one is a *projection*: arithmetic past the runner's
// fourteen-day horizon, with no row behind it, nothing committed and nothing to
// open. An operator who treats a forecast as a booking has been misled by the
// UI, not by the server, so the difference is drawn rather than merely stated —
// and the legend says which is which.
//
// **The grid's date axis is the READER's zone; each entry's time is the
// EVENT's.** §E gives the timezone to the event because every listing this
// replaces is written in the shard's local zone, but "what is happening this
// month" is a question about the month the person reading is living in. So the
// cell an event lands in is the reader's date, and the time beside it always
// carries the event's own zone — `20:00 Europe/Berlin` misreads as nothing.
const DAY_MS = 86_400_000
const STATUS_COLOR = {
failed: '#d98b84',
missed: '#d98b84',
paused: '#d9c184',
cancelled: 'var(--muted)',
running: '#8fc79a',
}
/** The event's own wall clock, which is the only time worth showing beside it. */
function localTime(instant, timezone) {
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(new Date(instant))
} catch {
return new Date(instant).toISOString().slice(11, 16)
}
}
/** The reader's own date key, which is what places an entry in a cell. */
const readerDayKey = (instant) => {
const d = new Date(instant)
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
}
/**
* The six-week grid a month view draws, Monday first.
*
* Always six weeks rather than however many the month needs: a grid that
* changes height as you page through it is a grid whose rows move under the
* cursor.
*/
function monthGrid(year, month) {
const first = new Date(year, month, 1)
const offset = (first.getDay() + 6) % 7
const start = new Date(year, month, 1 - offset)
return Array.from({ length: 42 }, (_, i) => new Date(start.getTime() + i * DAY_MS))
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
export default function EventsCalendar() {
const { user } = useAuth()
const today = useMemo(() => new Date(), [])
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [view, setView] = useState('month')
const [seriesId, setSeriesId] = useState('')
const [series, setSeries] = useState([])
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [managingSeries, setManagingSeries] = useState(false)
const mayAuthor = user?.role === 'admin' || user?.role === 'editor'
// The window is the grid's own span, not the month's: an entry in the leading
// or trailing week of the grid belongs to a neighbouring month and still has
// to be fetched, or the first row of every month renders empty.
const grid = useMemo(() => monthGrid(year, month), [year, month])
const window = useMemo(() => {
if (view === 'month') {
return { from: grid[0], to: new Date(grid[41].getTime() + DAY_MS) }
}
// The list view answers a different question — "what is coming" — so it runs
// forward from now rather than over a calendar month.
const from = new Date()
return { from, to: new Date(from.getTime() + 60 * DAY_MS) }
}, [view, grid])
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [calendar, seriesList] = await Promise.all([
api.admin.eventCalendar({
from: window.from.toISOString(),
to: window.to.toISOString(),
seriesId: seriesId || undefined,
}),
api.admin.eventSeries(),
])
setData(calendar)
setSeries(seriesList.series || [])
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [window.from, window.to, seriesId])
useEffect(() => {
load()
}, [load])
const byDay = useMemo(() => {
const map = new Map()
for (const entry of data?.entries || []) {
const key = readerDayKey(entry.scheduledFor)
if (!map.has(key)) map.set(key, [])
map.get(key).push(entry)
}
return map
}, [data])
const step = (delta) => {
const next = new Date(year, month + delta, 1)
setYear(next.getFullYear())
setMonth(next.getMonth())
}
if (loading && !data) return <Loading />
if (error && !data) return <ErrorState error={error} onRetry={load} />
const horizon = data?.horizon ? new Date(data.horizon) : null
return (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{/*
The stepper belongs to the MONTH view only. The list answers "what is
coming" and runs sixty days forward from now whatever month is
selected -- so paging it would be three controls that visibly do
nothing, which is the one thing this feature has refused since Phase
1. The heading says which question is being asked instead.
*/}
{view === 'month' && (
<>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(-1)}>
&larr;
</button>
<strong className="sans" style={{ fontSize: '0.95rem', minWidth: 150, textAlign: 'center' }}>
{`${MONTH_NAMES[month]} ${year}`}
</strong>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(1)}>
&rarr;
</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
disabled={year === today.getFullYear() && month === today.getMonth()}
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()) }}>
Today
</button>
</>
)}
{view === 'list' && (
<strong className="sans" style={{ fontSize: '0.95rem' }}>The next 60 days</strong>
)}
</div>
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', alignItems: 'center' }}>
<select className="select" value={seriesId} onChange={(e) => setSeriesId(e.target.value)}
style={{ minWidth: 170 }}>
<option value="">Every series</option>
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<button type="button" className="pill" aria-pressed={view === 'month'}
style={{ fontSize: '0.72rem', opacity: view === 'month' ? 1 : 0.5 }}
onClick={() => setView('month')}>
Month
</button>
<button type="button" className="pill" aria-pressed={view === 'list'}
style={{ fontSize: '0.72rem', opacity: view === 'list' ? 1 : 0.5 }}
onClick={() => setView('list')}>
List
</button>
{mayAuthor && (
<button type="button" className="pill" aria-pressed={managingSeries}
style={{ fontSize: '0.72rem', opacity: managingSeries ? 1 : 0.6 }}
onClick={() => setManagingSeries((v) => !v)}>
Series
</button>
)}
<Link to="/admin/events" className="pill" style={{ fontSize: '0.72rem' }}>Events</Link>
</div>
</div>
{/* The legend is not optional. The whole screen rests on the reader
knowing that a dashed entry is not a booking. */}
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
<span style={{ ...chip, borderStyle: 'solid' }}>Scheduled run</span> is a real occurrence with
a console it can be opened, paused and cancelled.{' '}
<span style={{ ...chip, borderStyle: 'dashed', opacity: 0.7 }}>Forecast</span> is what the
recurrence works out to beyond the {data?.horizonDays ?? 14}-day horizon: nothing is
committed yet and there is nothing to open.
{horizon && ` Everything up to ${horizon.toLocaleDateString()} is real.`}
</p>
{managingSeries && <SeriesManager series={series} onChanged={load} />}
{data?.truncated && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d9c184' }}>
This window has more than the calendar will draw. Narrow it by series, or page to a
shorter span.
</p>
)}
{view === 'month' ? (
<div className="panel-flat" style={{ padding: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 4 }}>
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => (
<div key={d} className="sans dim" style={{ fontSize: '0.72rem', textAlign: 'center', padding: '2px 0' }}>
{d}
</div>
))}
{grid.map((day) => {
const entries = byDay.get(readerDayKey(day)) || []
const outside = day.getMonth() !== month
const isToday = readerDayKey(day) === readerDayKey(today)
return (
<div key={day.toISOString()}
style={{
minHeight: 84,
padding: 4,
borderRadius: 4,
border: isToday ? '1px solid var(--accent, #8fc79a)' : '1px solid transparent',
background: outside ? 'transparent' : 'rgba(255,255,255,0.03)',
opacity: outside ? 0.4 : 1,
}}>
<div className="sans dim" style={{ fontSize: '0.7rem', marginBottom: 3 }}>
{day.getDate()}
</div>
{entries.map((entry) => <EntryChip key={entryKey(entry)} entry={entry} />)}
</div>
)
})}
</div>
</div>
) : (
<div className="panel-flat" style={{ padding: 4 }}>
{(data?.entries || []).length === 0 ? (
<p className="sans dim" style={{ padding: 14, margin: 0, fontSize: '0.84rem' }}>
Nothing is scheduled in the next sixty days.{' '}
{mayAuthor && <Link to="/admin/events/new">Author an event</Link>}
</p>
) : (
<table className="table">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Series</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{(data?.entries || []).map((entry) => (
<tr key={entryKey(entry)} style={{ opacity: isProjected(entry) ? 0.7 : 1 }}>
<td className="sans" style={{ fontSize: '0.82rem', whiteSpace: 'nowrap' }}>
{new Date(entry.scheduledFor).toLocaleDateString()}{' '}
<span className="dim">
{localTime(entry.scheduledFor, entry.timezone)} {entry.timezone}
</span>
</td>
<td className="sans" style={{ fontSize: '0.84rem' }}>
{entry.runId ? (
<Link to={`/admin/events/runs/${entry.runId}`}>{entry.title}</Link>
) : (
<Link to={`/admin/events/${entry.definitionId}`}>{entry.title}</Link>
)}
{entry.adjusted === 'gap' && (
<span className="dim" title="Daylight saving skips the time this was authored at, so it moves forward to the next one that exists">
{' '}(clocks change)
</span>
)}
</td>
<td className="sans dim" style={{ fontSize: '0.8rem' }}>{entry.seriesName || '—'}</td>
<td className="sans" style={{ fontSize: '0.8rem', color: STATUS_COLOR[entry.status] }}>
{isProjected(entry) ? <span className="dim">Forecast</span> : runStatusWord(entry.status)}
{entry.waitingSteps > 0 && (
<span style={{ color: '#d9c184' }}> · waiting on a person</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</>
)
}
// A projection has no run id, so the definition and the instant are its
// identity — the same pair the server dedupes projections against.
const entryKey = (entry) =>
entry.runId ? `run-${entry.runId}` : `proj-${entry.definitionId}-${entry.scheduledFor}`
const chip = {
display: 'inline-block',
padding: '0 5px',
borderRadius: 3,
borderWidth: 1,
border: '1px solid var(--muted)',
fontSize: '0.72rem',
}
/**
* The arcs, managed where they are used.
*
* A series is a label, not authored content — nothing pins one and no run
* references one — so this is a small inline panel rather than a screen of its
* own, and it lives on the calendar because the calendar is what makes an arc
* visible in the first place. §I: *"Royal Spy Mission → Risky Partner → Message
* From the Void" is continuity that exists nowhere in the tooling this replaces.*
*
* The delete is a real delete, and it says what it will detach before it
* happens: `series_id` is ON DELETE SET NULL, so the definitions survive without
* an arc and re-attaching one is a dropdown in the editor. Nothing is destroyed,
* which is why this is the one delete in this feature that is not an archive.
*/
function SeriesManager({ series, onChanged }) {
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState(null)
const run = async (fn) => {
setBusy(true)
setProblem(null)
try {
await fn()
await onChanged()
} catch (err) {
setProblem(err?.body?.errors?.join('; ') || err?.message || 'That did not work')
} finally {
setBusy(false)
}
}
return (
<div className="panel-flat" style={{ padding: 14, marginBottom: 12 }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Series</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.78rem' }}>
An arc several events form together. The order here is where a series sits among the
others; where an event sits <em>within</em> its arc is that event&rsquo;s own order, in the
editor.
</p>
{problem && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{problem}</p>
)}
{series.map((s) => (
<div key={s.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input className="input" defaultValue={s.name} disabled={busy} style={{ flex: 1 }}
onBlur={(e) => {
const next = e.target.value.trim()
if (next && next !== s.name) {
run(() => api.admin.updateEventSeries(s.id, { name: next, description: s.description, ordering: s.ordering }))
}
}} />
<input className="input" type="number" defaultValue={s.ordering} disabled={busy}
style={{ width: 72 }} aria-label={`Order of ${s.name}`}
onBlur={(e) => {
const next = Number(e.target.value)
if (Number.isInteger(next) && next !== s.ordering) {
run(() => api.admin.updateEventSeries(s.id, { name: s.name, description: s.description, ordering: next }))
}
}} />
<span className="sans dim" style={{ fontSize: '0.76rem', minWidth: 70 }}>
{s.definitionCount} event{s.definitionCount === 1 ? '' : 's'}
</span>
<button type="button" className="pill" disabled={busy} style={{ fontSize: '0.7rem' }}
onClick={() => {
// The count is in the question, because the consequence of this
// delete is entirely about the rows it does not delete.
const ask = s.definitionCount
? `Delete "${s.name}"? ${s.definitionCount} event(s) will keep their content and lose this series.`
: `Delete "${s.name}"?`
// eslint-disable-next-line no-alert
if (window.confirm(ask)) run(() => api.admin.deleteEventSeries(s.id))
}}>
Delete
</button>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<input className="input" value={name} placeholder="New series name" disabled={busy}
style={{ flex: 1 }} onChange={(e) => setName(e.target.value)} />
<button type="button" className="pill" disabled={busy || !name.trim()} style={{ fontSize: '0.72rem' }}
onClick={() => run(async () => {
await api.admin.createEventSeries({ name: name.trim() })
setName('')
})}>
Add
</button>
</div>
</div>
)
}
function EntryChip({ entry }) {
const projected = isProjected(entry)
const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}`
return (
<Link
to={to}
className="sans"
title={`${entry.title}${localTime(entry.scheduledFor, entry.timezone)} ${entry.timezone}${projected ? ' (forecast)' : `${runStatusWord(entry.status)}`}`}
style={{
display: 'block',
fontSize: '0.7rem',
padding: '1px 4px',
marginBottom: 2,
borderRadius: 3,
borderLeft: `2px ${projected ? 'dashed' : 'solid'} ${STATUS_COLOR[entry.status] || 'var(--accent, #8fc79a)'}`,
background: projected ? 'transparent' : 'rgba(255,255,255,0.05)',
opacity: projected ? 0.7 : 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textDecoration: 'none',
}}>
<span className="dim">{localTime(entry.scheduledFor, entry.timezone)}</span> {entry.title}
{entry.waitingSteps > 0 && <span style={{ color: '#d9c184' }}> </span>}
</Link>
)
}

View File

@@ -0,0 +1,119 @@
import { useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { api } from '../../../api/client.js'
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
// live from the admin SSE channel (house.update / house.remove).
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
function DecayBadge({ decay, isIdoc }) {
const label = isIdoc ? 'IDOC' : decay
if (!label) return null
const tone = DECAY_TONE[label] || 'var(--muted)'
return (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
{label}
</span>
)
}
function ownerLabel(h) {
return h.ownerName || h.ownerAcct || null
}
function HouseRow({ h }) {
const owner = ownerLabel(h)
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.name || 'An unnamed house'}
</strong>
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
</div>
</div>
{h.price != null && (
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
</div>
)}
</div>
)
}
export default function HousesAdmin() {
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
// Full registry deltas ride the admin SSE channel (never the public one).
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
const [q, setQ] = useState('')
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev.serial) continue
if (ev.kind === 'house.update') {
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
} else if (ev.kind === 'house.remove') {
map.delete(ev.serial)
} else if (ev.kind === 'house.decay') {
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
}
}
return [...map.values()]
}, [data, events])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the house registry." />
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
{board.length.toLocaleString()} houses
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
</p>
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
</div>
{board.length === 0 ? (
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
)}
{board.length > 0 && filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match {q}.</p>
)}
</section>
)
}

View File

@@ -1,424 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import { statusOf, actionsFor, declarationNoteFor, needsRestart, parseHosts } from '../../../lib/moduleAdmin.js'
import { api } from '../../../api/client.js'
// Installed modules: install from a release URL, enable, disable, uninstall,
// purge, and restart the server so the changes take effect.
//
// Phase 4, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.2. Everything that
// decides what a row SAYS and which buttons it offers lives in
// lib/moduleAdmin.js, which is plain JS and has tests; this file renders it.
//
// Two things about this screen are unlike the rest of the admin panel and are
// deliberate:
//
// 1. **Restart is a banner, not a per-row button.** A restart is a property of
// the server, not of a module. Offering it on five rows would suggest
// otherwise, and an operator who installed three modules should restart
// once.
// 2. **Disable is the only action that takes effect immediately.** Everything
// else is "true after the next boot", because the loader reads the volume
// at require time (§1.12). The buttons say which they are.
const TONE = {
ok: '#7fd0a4',
warn: 'var(--accent)',
bad: '#d98b84',
idle: 'var(--muted)',
}
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
function Pill({ tone, children }) {
return (
<span
className="badge"
style={{ color: TONE[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
{children}
</span>
)
}
// ── Install ────────────────────────────────────────────────────────────────
function InstallForm({ sourceHosts, onInstalled }) {
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState(null)
async function submit(e) {
e.preventDefault()
setError('')
setResult(null)
if (!url.trim()) return setError('Paste the URL of a release install manifest.')
setBusy(true)
try {
const res = await api.admin.installModule(url.trim())
setResult(res)
setUrl('')
await onInstalled()
} catch (err) {
// The server's message is written to be read by whoever pasted the URL —
// which host was refused, which hash did not match, what the archive
// contained. Replacing it with something friendlier would throw away the
// only part that helps.
setError(err.message || 'Could not install that module.')
} finally {
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Install a module</div>
<form onSubmit={submit} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 380px' }}>
<span className="field-label">Release install-manifest URL</span>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input"
placeholder="https://gitea.example.com/org/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json"
/>
</label>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Installing…' : 'Install'}
</button>
</form>
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
The bundle is downloaded, checked against the <code>sha256</code> its release published, and
unpacked onto the modules volume. It starts serving after a restart.{' '}
{sourceHosts.length === 0
? 'No source hosts are allowed yet — add one below before installing.'
: `Allowed hosts: ${sourceHosts.join(', ')}.`}
</p>
{error && <p className="sans" style={{ margin: '12px 0 0', color: TONE.bad, fontSize: '0.85rem' }}>{error}</p>}
{result && (
<p className="sans" style={{ margin: '12px 0 0', color: TONE.ok, fontSize: '0.85rem' }}>
{result.replaced ? 'Upgraded' : 'Installed'} {result.module?.name} v{result.module?.version}. Restart to load it.
</p>
)}
</div>
)
}
// ── The restart banner ─────────────────────────────────────────────────────
function RestartBanner({ onDone }) {
const [busy, setBusy] = useState(false)
const [sent, setSent] = useState(false)
async function restart() {
// Said plainly, because it is true and because the failure mode is bad: a
// deployment with no supervisor does not come back on its own.
const ok = window.confirm(
'Restart the server now?\n\n'
+ 'The site will be briefly unavailable. It comes back on its own only if something is '
+ 'supervising the process — the shipped Docker Compose file does. If you are running '
+ '`npm start` by hand, you will have to start it again yourself.',
)
if (!ok) return
setBusy(true)
try {
await api.admin.restartServer()
setSent(true)
// Nothing is coming back on this connection: the process is exiting. Give
// the supervisor a moment and then reload, which is what the operator was
// about to do anyway.
setTimeout(() => { if (onDone) onDone() }, 6000)
} catch {
// A failed request here is expected as often as not — the process can win
// the race and drop the socket before the response lands.
setSent(true)
setTimeout(() => { if (onDone) onDone() }, 6000)
} finally {
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: 18, marginBottom: 22, borderColor: 'var(--accent)' }}>
<div style={{ display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 320px' }}>
<div className="field-label" style={{ marginBottom: 4 }}>Restart needed</div>
<p className="sans" style={{ margin: 0, fontSize: '0.84rem', color: 'var(--muted)' }}>
{sent
? 'Restarting. This page will reload once the server is back.'
: 'Modules are read from disk when the server starts, so an install, an uninstall or a re-enable only takes effect after a restart.'}
</p>
</div>
<button type="button" className="btn btn-primary btn-sq" disabled={busy || sent} onClick={restart}>
{sent ? 'Restarting…' : 'Restart the server'}
</button>
</div>
</div>
)
}
// ── The source allowlist ───────────────────────────────────────────────────
function SourceHosts({ hosts, onSaved }) {
const [value, setValue] = useState(hosts.join(', '))
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
useEffect(() => { setValue(hosts.join(', ')) }, [hosts])
async function save(e) {
e.preventDefault()
setError('')
setSaved(false)
setBusy(true)
try {
await api.admin.setModuleSources(value)
setSaved(true)
await onSaved()
} catch (err) {
setError(err.message || 'Could not save the allowlist.')
} finally {
setBusy(false)
}
}
const parsed = parseHosts(value)
return (
<div className="panel" style={{ padding: 22, marginTop: 22 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Where modules may be installed from</div>
<form onSubmit={save} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 380px' }}>
<span className="field-label">Allowed hosts</span>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
className="input"
placeholder="gitea.example.com, releases.example.org"
/>
</label>
<button type="submit" disabled={busy} className="btn btn-sq">{busy ? 'Saving…' : 'Save'}</button>
</form>
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
Installing a module runs its code inside this server, so only hosts listed here may be
installed from over HTTPS, and re-checked on every redirect. An empty list blocks all
installs.{' '}
{parsed.length > 0 && <>Will be saved as: <code>{parsed.join(', ')}</code>.</>}
</p>
{error && <p className="sans" style={{ margin: '10px 0 0', color: TONE.bad, fontSize: '0.85rem' }}>{error}</p>}
{saved && !error && <p className="sans" style={{ margin: '10px 0 0', color: TONE.ok, fontSize: '0.85rem' }}>Saved.</p>}
</div>
)
}
// ── One module ─────────────────────────────────────────────────────────────
function ModuleRow({ m, onChanged, onError }) {
const [busy, setBusy] = useState('')
const status = statusOf(m)
const actions = actionsFor(m)
const note = declarationNoteFor(m)
async function run(name, fn) {
setBusy(name)
try {
await fn()
await onChanged()
} catch (err) {
onError(err.message || `Could not ${name} ${m.id}.`)
} finally {
setBusy('')
}
}
const disable = () => run('disable', () => api.admin.disableModule(m.id))
const enable = () => run('enable', () => api.admin.enableModule(m.id))
function uninstall() {
// The purge choice is made HERE and only here, because purge.sql lives
// inside the directory the uninstall is about to delete — there is no
// "purge it later" (§2.7.2 decision 5). Two prompts rather than one, so
// "delete the data too" is never something you agree to by reflex.
if (!window.confirm(`Uninstall ${m.name}?\n\nIts files are removed. Its data is kept unless you ask otherwise next.`)) return
let purge = false
if (m.canPurge) {
purge = window.confirm(
`Also permanently delete ${m.name}'s data?\n\n`
+ 'This drops its tables and cannot be undone. This is the only moment it can be offered — '
+ 'the script that does it is part of the files being removed.\n\n'
+ 'OK deletes the data. Cancel keeps it.',
)
}
return run('uninstall', () => api.admin.uninstallModule(m.id, { purge }))
}
function purge() {
if (!window.confirm(`Permanently delete ${m.name}'s data?\n\nThis drops its tables and cannot be undone.`)) return
return run('purge', () => api.admin.purgeModule(m.id))
}
const forget = () => run('forget', () => api.admin.uninstallModule(m.id))
return (
<tr>
<td className="adm-td" style={{ color: 'var(--text)' }}>
<div style={{ fontWeight: 600 }}>{m.name}</div>
<div className="dim" style={{ fontSize: '0.76rem' }}>
{/* A declared module that has never installed has no version to show —
only the one MODULES asks for, which the status column carries. */}
{m.id}{m.version ? ` · v${m.version}` : ''}
</div>
{m.capabilities?.length > 0 && (
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>{m.capabilities.join(' · ')}</div>
)}
</td>
<td className="adm-td">
<Pill tone={status.tone}>{status.label}</Pill>
<div className="dim" style={{ fontSize: '0.74rem', marginTop: 4, maxWidth: 380 }}>{status.detail}</div>
{/* The environment's declaration, on its own line: a module can be
running fine while its declared upgrade is failing, and the status
above can only be one of those two things. */}
{note && (
<div
style={{
fontSize: '0.74rem',
marginTop: 4,
maxWidth: 380,
color: note.tone === 'warn' ? TONE.warn : 'var(--muted)',
}}
>
{note.text}
</div>
)}
</td>
<td className="adm-td dim" style={{ fontSize: '0.74rem' }}>
{/* Provenance. Null for a directory placed on the volume by hand, which
stays a supported install — so it is shown as that, not as missing.
A declared module can also reach a boot with no provenance: the
no-op path never fetches, so it has no sha256 to record and no
reason to write a row. Saying "by hand" there would be the one
wrong answer. */}
{m.source ? (
<>
<div style={{ wordBreak: 'break-all', maxWidth: 260 }}>{m.source}</div>
{m.sha256 && <div style={{ marginTop: 2 }}>sha256 {m.sha256.slice(0, 12)}</div>}
</>
) : (
<span>{m.declared ? 'From the declared module set' : 'Placed on the volume by hand'}</span>
)}
{m.installedAt && <div style={{ marginTop: 2 }}>{dateTime(m.installedAt)}</div>}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div style={{ display: 'inline-flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{actions.disable.shown && (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={Boolean(busy)} onClick={disable}>
{busy === 'disable' ? 'Stopping…' : 'Disable'}
</button>
)}
{actions.enable.shown && (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={Boolean(busy)} onClick={enable}>
{busy === 'enable' ? 'Enabling…' : 'Enable'}
</button>
)}
{actions.purge.shown && (
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem', ...DANGER, opacity: actions.purge.enabled ? 1 : 0.45 }}
disabled={Boolean(busy) || !actions.purge.enabled}
title={actions.purge.enabled ? undefined : actions.purge.reason}
onClick={purge}
>
{busy === 'purge' ? 'Purging…' : 'Purge data'}
</button>
)}
{actions.uninstall.shown && (
<button type="button" className="pill" style={{ fontSize: '0.72rem', ...DANGER }} disabled={Boolean(busy)} onClick={uninstall}>
{busy === 'uninstall' ? 'Removing…' : 'Uninstall'}
</button>
)}
{actions.forget.shown && (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={Boolean(busy)} onClick={forget}>
{busy === 'forget' ? 'Clearing…' : 'Clear the row'}
</button>
)}
</div>
</td>
</tr>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
export default function ModulesAdmin() {
const [data, setData] = useState(null)
const [error, setError] = useState('')
const [actionError, setActionError] = useState('')
const load = useCallback(async () => {
setError('')
try {
setData(await api.admin.listModules())
} catch {
setError('Could not load installed modules.')
}
}, [])
useEffect(() => { load() }, [load])
if (error) return <ErrorState message={error} />
if (!data) return <Loading />
const modules = data.modules || []
const sourceHosts = data.sourceHosts || []
return (
<section>
{needsRestart(modules) && <RestartBanner onDone={() => window.location.reload()} />}
<InstallForm sourceHosts={sourceHosts} onInstalled={load} />
{actionError && (
<p className="sans" style={{ margin: '0 0 14px', color: TONE.bad, fontSize: '0.85rem' }}>{actionError}</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Module</th>
<th className="adm-th">Status</th>
<th className="adm-th">Installed from</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{modules.length === 0 && (
<tr>
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>
No modules installed. Paste a release install-manifest URL above to add one.
</td>
</tr>
)}
{modules.map((m) => (
<ModuleRow key={m.id} m={m} onChanged={load} onError={setActionError} />
))}
</tbody>
</table>
</div>
<SourceHosts hosts={sourceHosts} onSaved={load} />
</section>
)
}

View File

@@ -13,8 +13,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { withModuleNav } from '../../../modules/nav.js'
import { useFeatureGate } from '../../../modules/features.jsx'
import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js'
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
import PublicNavTree from './PublicNavTree.jsx'
import { parseJsonSetting } from '../../../lib/settingsJson.js'
@@ -35,8 +34,7 @@ import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'
// Three things shape the screen:
//
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
// the base array run through their role and the feature gates of whichever
// module registered each row (client/src/modules/featureGate.js). An
// the base array run through their role and this shard's feature gates. An
// admin cannot drag in, and so can never accidentally advertise, something
// they cannot see themselves. An override on a row they cannot see is
// carried through their save untouched rather than quietly reset.
@@ -246,7 +244,7 @@ export function Row({ row, id, destinations, destination, onDestination, onChang
export default function NavEditor() {
const { user } = useAuth()
const { refresh: refreshSite } = useSite()
const isVisible = useFeatureGate()
const shardFeatures = useShardFeatures()
const [tab, setTab] = useState('nav_public')
// Per nav: the editable groups, the overrides as loaded (so a row this admin
// cannot see survives their save), and whether a settings row exists at all.
@@ -257,39 +255,25 @@ export default function NavEditor() {
const [saved, setSaved] = useState('')
const [dirty, setDirty] = useState({})
// The palette: each base nav, filtered to what THIS admin can see (§8.1). The
// public nav's gates are the shard-feature ones; the admin nav's are roles.
// The player portal has no gates at all.
// The nav as coded, unfiltered. The palette below is what this admin may EDIT;
// this is what still EXISTS, and the two are different questions. Saving needs
// both: an entry for a row their palette filtered out must be carried through
// rather than reset, and only an entry for a route the code no longer declares
// at all should be dropped.
//
// Each nav is the coded array with every installed module's rows already
// interleaved (modules/nav.js) — the same array the layout renders, which is
// what makes a module row editable here at all: the override merge is keyed by
// `to` and drops a key the base it is handed does not declare, so a nav built
// from core alone would silently discard every stored override on a module row
// the moment it was saved.
const fullNavs = useMemo(
() => ({
nav_public: withModuleNav(PUBLIC_NAV, 'public'),
nav_admin: withModuleNav(ADMIN_NAV, 'admin'),
nav_player: withModuleNav(PLAYER_NAV, 'player'),
}),
[],
)
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV }
// The palette: each base nav, filtered to what THIS admin can see (§8.1). Two
// gates, and neither is core's own opinion any more — `roles` on a row, and
// the owning module's answer for a row that names a `feature`.
const palettes = useMemo(
() => ({
nav_public: fullNavs.nav_public.filter(isVisible),
nav_admin: fullNavs.nav_admin
.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
.filter((g) => g.items.length > 0),
nav_player: fullNavs.nav_player.filter(isVisible),
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)),
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter(
(g) => g.items.length > 0,
),
nav_player: PLAYER_NAV,
}),
[fullNavs, isVisible, user?.role],
[shardFeatures, user?.role],
)
useEffect(() => {
@@ -459,8 +443,8 @@ export default function NavEditor() {
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged this
only decides what is advertised, and it can never show anyone a link their role, or the visibility
settings of an installed module, would hide.
only decides what is advertised, and it can never show anyone a link their role or this shard&rsquo;s
visibility settings would hide.
</p>
{/* ── Tabs ───────────────────────────────────────────────── */}
@@ -553,8 +537,8 @@ export default function NavEditor() {
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
Only entries you can see yourself are listed. Anything hidden from you by your role, or by a
module&rsquo;s visibility settings, keeps whatever it was already set to.
Only entries you can see yourself are listed. Anything hidden from you by your role or by Shard
Visibility keeps whatever it was already set to.
</p>
</section>
)

View File

@@ -177,15 +177,14 @@ const delStyle = {
}
// ── Announcement status panel ────────────────────────────────────────────────
// Shows each delivery leg's state for a published news post and offers a per-leg
// retry (useful after fixing the sidecar / news channel without re-publishing).
// Only rendered for news posts in edit mode; renders nothing until the post has
// actually been announced (no job row yet → nothing to show).
//
// The legs and their labels come from the JOB, not from a constant here: which
// legs exist is decided by what the server has registered, so an installed module
// brings its own leg and this panel renders it with no client change
// (docs/website/MODULE_SYSTEM.md §1.8).
// Shows the town-crier + Discord delivery state for a published news post and
// offers a per-leg retry (useful after fixing the sidecar / news channel without
// re-publishing). Only rendered for news posts in edit mode; renders nothing
// until the post has actually been announced (no job row yet → nothing to show).
const LEG_META = {
towncrier: { label: 'In-game town crier' },
discord: { label: 'Discord #news' },
}
const STATUS_STYLE = {
done: { color: '#7bbf8f', label: 'delivered' },
pending: { color: '#d9b84a', label: 'pending' },
@@ -228,12 +227,14 @@ function AnnouncePanel({ postId }) {
return (
<div style={panelStyle}>
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
{(job.legs || []).map(({ leg, label, status, last_error: err }) => {
{['towncrier', 'discord'].map((leg) => {
const status = job[`${leg}_status`]
const err = job[`${leg}_last_error`]
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
return (
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{label}</span>
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span>
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}> {s.label}</span>
{status !== 'done' && (
<button

View File

@@ -3,7 +3,6 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx'
import TeamForumSettings from './TeamForumSettings.jsx'
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
@@ -36,6 +35,18 @@ const FIELDS = [
],
fallback: 'disabled',
},
{
key: 'game_account_signup',
label: 'Game-account creation',
help: 'Whether players can create a GAME account (for the game client) from the site. The game servers 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.',
options: [
{ 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' },
],
fallback: 'disabled',
},
]
export default function SettingsAdmin() {
@@ -144,8 +155,6 @@ export default function SettingsAdmin() {
</div>
</div>
<TeamForumSettings />
<EmailDelivery />
</section>
)

View File

@@ -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 (
<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>
)
}
// ── 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} />
<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>
<TownCrier />
<AdminLiveFeed />
</section>
)
}

View File

@@ -0,0 +1,291 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { describe } from '../../../lib/shardEvents.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
// unban) and the help-page support queue, plus a live audit log. Open to admins
// and moderators. The acting staff member (`actor`) is attached server-side from
// the session — nothing here sends it — so every action is attributable.
function Flash({ ok, err }) {
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
return null
}
// ── Broadcast ────────────────────────────────────────────────────────────────
function Broadcast() {
const [text, setText] = useState('')
const [hue, setHue] = useState('')
const [busy, setBusy] = useState(false)
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
async function send() {
if (!text.trim()) return setErr('Enter a message.')
setBusy(true); setOk(''); setErr('')
try {
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
setOk('Broadcast sent.')
setText('')
} catch (e) {
setErr(e.message || 'Could not broadcast.')
} finally {
setBusy(false)
}
}
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
A system message shown to everyone online right now.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message</span>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Hue (optional)</span>
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
function AccountActions() {
const [account, setAccount] = useState('')
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const acct = account.trim()
function guard() {
if (!acct) {
setErr('Enter an account name.')
return false
}
return true
}
async function run(label, fn, done) {
if (!guard()) return
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: acct }), (r) => {
const n = r?.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked ${acct}${sessions}.`
})
const ban = () =>
run(
'ban',
() =>
api.admin.shardOps.ban({
account: acct,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned ${acct}${when}.`
},
)
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
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)' }}>Account actions</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Account</span>
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'block', maxWidth: 200 }}>
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
<span className="field-label">Ban reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Support (help-page) queue ────────────────────────────────────────────────
function PageRow({ page, onDone }) {
const [message, setMessage] = useState('')
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
async function respond(close) {
if (!message.trim()) return setErr('Enter a reply first.')
setBusy(close ? 'respond-close' : 'respond'); setErr('')
try {
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
onDone()
} catch (e) {
setErr(e.message || 'Could not send.')
setBusy('')
}
}
async function close() {
setBusy('close'); setErr('')
try {
await api.admin.shardOps.closePage(page.pageId)
onDone()
} catch (e) {
setErr(e.message || 'Could not close.')
setBusy('')
}
}
return (
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{page.sender?.name || page.pageId}
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
</div>
</div>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
</div>
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
</div>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
</div>
)
}
function SupportQueue() {
const [pages, setPages] = useState(null)
const [err, setErr] = useState('')
const pollRef = useRef(null)
const load = useCallback(async () => {
try {
setPages(await api.admin.shardOps.pages())
} catch {
setErr('Could not load the support queue.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 7000)
return () => clearInterval(pollRef.current)
}, [load])
let queueBody
if (pages == null) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
} else if (pages.length === 0) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
} else {
queueBody = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
</div>
)
}
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)' }}>Support queue</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Open help pages from players. A reply reaches them in game (or on their next login).
</p>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
{queueBody}
</section>
)
}
// ── Audit log ────────────────────────────────────────────────────────────────
// Seeded from the stored admin.audit history, then kept live from the admin SSE
// channel (which carries every kind — we filter to admin.audit here).
function AuditLog() {
const [seed, setSeed] = useState([])
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
useEffect(() => {
api.admin.shardOps
.audit(50)
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
.catch(() => setSeed([]))
}, [])
// Live events on top; fall back to the seed for anything older than the live tail.
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
{rows.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<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>
)
}
export default function ShardOps() {
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
<Broadcast />
<AccountActions />
<SupportQueue />
<AuditLog />
</section>
)
}

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// ── Admin · Shard visibility ────────────────────────────────────────────────
//
// Who may see which shard surface, and which sensitive fields within it.
// Admin-only, because this decides what ANONYMOUS visitors get.
//
// Two things the UI must communicate honestly, because they are not negotiable
// server-side (see docs/link/v3.md §3.4):
// • acct / webId are admin-only always and are not listed as editable fields.
// • an event kind the server doesn't know about never reaches anyone below
// admin, whatever is set here.
//
// Defaults reproduce the behavior the site had before this panel existed, so a
// fresh install shows "everything as it was" rather than an empty form.
const RUNG_LABEL = {
anonymous: 'Everyone',
logged_in: 'Signed in',
player: 'Linked players',
staff: 'Staff',
admin: 'Admins only',
}
const RUNG_HINT = {
anonymous: 'Visible to anyone, signed in or not.',
logged_in: 'Any signed-in account, linked or not.',
player: 'Accounts with a linked game account. Staff always qualify.',
staff: 'Admins and moderators.',
admin: 'Admins only.',
}
const FEATURE_LABEL = {
status: 'Shard status',
activity: 'Activity feed',
champs: 'Champion spawns',
guilds: 'Guilds',
governors: 'Town governors',
houses: 'Houses / IDOC',
presence: 'Players online',
ruleset: 'Shard rules',
atlas: 'Spawn atlas',
leaderboards: 'Leaderboards',
market: 'Marketplace',
}
const FEATURE_HINT = {
status: 'Connection state, online count, gold-supply series.',
activity: 'Deaths, kills, skill gains, quests, logins.',
champs: 'The live champion / mini-champ / sea-boss board.',
guilds: 'Guild rosters, alliances and leaders.',
governors: 'City Loyalty governors, elections and term history.',
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
presence: 'Population aggregate and the staff-online widget.',
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
leaderboards: 'Point and loyalty standings across every points system.',
market: 'The shard-wide player-vendor index.',
}
const FIELD_LABEL = {
owner: 'House owner',
price: 'House price',
location: 'In-game location (map + coordinates)',
connect: 'Server connect address',
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
// projection matches literal JSON keys, so the rule cannot be spelled after the
// field's meaning. The label is what carries the meaning to the admin.
name: 'Character names on leaderboards',
ownerName: 'Vendor owner name',
// One rule, one key — `location` is a nested object on both the wire frame and
// the stored read model precisely so that hiding it takes the facet, the
// coordinates, the region and the house together.
ownerSerial: 'Vendor owner character id',
}
function RungSelect({ value, onChange, ladder, disabled }) {
return (
<select
className="input"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{ maxWidth: 200 }}
>
{ladder.map((rung) => (
<option key={rung} value={rung}>
{RUNG_LABEL[rung] || rung}
</option>
))}
</select>
)
}
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
const fields = Object.entries(settings.fields || {})
const changed =
defaults &&
(settings.enabled !== defaults.enabled ||
settings.audience !== defaults.audience ||
settings.stream !== defaults.stream ||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
return (
<div
style={{
border: '1px solid var(--line)',
borderRadius: 10,
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 12,
opacity: settings.enabled ? 1 : 0.62,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{FEATURE_LABEL[name] || name}
{changed && (
<span
className="sans"
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
>
changed
</span>
)}
</h3>
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
{FEATURE_HINT[name]}
</p>
</div>
<label
className="sans"
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
>
<input
type="checkbox"
checked={settings.enabled}
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
/>
Enabled
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Who can see it</span>
<RungSelect
value={settings.audience}
ladder={ladder}
disabled={!settings.enabled}
onChange={(audience) => onPatch(name, { audience })}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
{RUNG_HINT[settings.audience]}
</span>
</label>
<label
className="sans"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
>
<input
type="checkbox"
checked={settings.stream}
disabled={!settings.enabled}
onChange={(e) => onPatch(name, { stream: e.target.checked })}
/>
Live updates
</label>
</div>
{fields.length > 0 && (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
Sensitive fields
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
{fields.map(([field, rung]) => (
<label key={field} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
{FIELD_LABEL[field] || field}
</span>
<RungSelect
value={rung}
ladder={ladder}
disabled={!settings.enabled}
onChange={(level) =>
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
}
/>
</label>
))}
</div>
</div>
)}
</div>
)
}
export default function ShardVisibility() {
const [config, setConfig] = useState(null)
const [defaults, setDefaults] = useState(null)
const [ladder, setLadder] = useState([])
const [lockedFields, setLockedFields] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.getShardVisibility()
setConfig(data.features)
setDefaults(data.defaults)
setLadder(data.ladder || [])
setLockedFields(data.lockedFields || [])
} catch (err) {
setError(err.message || 'Could not load visibility settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
function patch(name, changes) {
setMsg('')
setConfig((prev) => {
const next = { ...prev[name], ...changes }
// `fieldRules` in the API is `fields` in the effective config.
if (changes.fieldRules) {
next.fields = changes.fieldRules
delete next.fieldRules
}
return { ...prev, [name]: next }
})
}
async function save() {
setSaving(true)
setMsg('')
setError('')
try {
const body = {}
for (const [name, s] of Object.entries(config)) {
body[name] = {
enabled: s.enabled,
audience: s.audience,
stream: s.stream,
fieldRules: s.fields || {},
}
}
const data = await api.admin.saveShardVisibility(body)
setConfig(data.features)
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setSaving(false)
}
}
function resetToDefaults() {
setMsg('')
setConfig(structuredClone(defaults))
}
if (loading) return <Loading />
if (error && !config) return <ErrorState message={error} onRetry={load} />
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Shard visibility
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
Choose who can see each shard surface on the public site, and how much detail they get.
Turning a feature off hides it entirely its pages return not found rather than
revealing that it exists. Live updates controls whether the feature streams changes in
real time; the pages still work without it, they just refresh on load.
</p>
{lockedFields.length > 0 && (
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong>
game account names and website user ids are never shown below admin, on any surface. They
arent visible in game either, so publishing them would disclose something the shard
itself doesnt.
</p>
)}
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{Object.entries(config).map(([name, settings]) => (
<FeatureRow
key={name}
name={name}
settings={settings}
defaults={defaults?.[name]}
ladder={ladder}
onPatch={patch}
/>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
{saving ? 'Saving…' : 'Save changes'}
</button>
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
Restore defaults
</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>
</div>
)
}

View File

@@ -0,0 +1,285 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
//
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
// this panel exists for the three things a restart cannot do:
//
// • point it at a different tree,
// • apply a map change without restarting, and
// • answer a refresh that was parsed but deliberately NOT applied because it
// would remove a facet.
//
// That last one is the reason the panel is worth building. Losing a facet looks
// exactly like a half-copied or mid-update tree, and boot cannot tell them
// apart — so it stages the decision for a human instead of guessing. Until
// someone decides here, the site keeps serving the atlas it already had.
// A refresh reports its outcome rather than throwing (the boot path must never
// be stopped by a bad tree), so these are answers, not errors — the panel says
// what happened in the shard's terms instead of showing a failure box.
const OUTCOME = {
imported: (r) =>
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
unchanged: (r) =>
r.reason === 'refresh previously rejected'
? 'Unchanged — this exact tree was already reviewed and declined.'
: 'Unchanged — the tree matches what is already loaded.',
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
rejected: () => 'Declined. It will not be offered again until the tree changes.',
}
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
function Row({ label, children }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 16,
padding: '7px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim">{label}</span>
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
</div>
)
}
function PendingReview({ pending, busy, onApprove, onReject }) {
const declined = pending.status === 'rejected'
return (
<section
style={{
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
borderRadius: 10,
padding: 16,
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
}}
>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
</h3>
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
{declined ? (
<>
This tree was reviewed and declined, so it is not offered again until the files change.
Approving now applies it anyway.
</>
) : (
<>
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
</strong>
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
not applied. Approving re-parses the tree as it is right now if you have since fixed the
mount, what lands is the corrected import.
</>
)}
</p>
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
Approve and import
</button>
{!declined && (
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
Keep the current atlas
</button>
)}
</div>
</section>
)
}
export default function SpawnAtlas() {
const [status, setStatus] = useState(null)
const [path, setPath] = useState('')
const [force, setForce] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.atlas.status()
setStatus(data)
setPath(data.path || '')
} catch (err) {
setError(err.message || 'Could not load atlas status.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
// Every mutating action shares this: run it, report what it said, then reload
// status so the panel reflects the world rather than what we assumed happened.
async function run(action, fn) {
setBusy(true)
setMsg('')
setError('')
try {
const result = await fn()
setMsg(describe(result))
const fresh = await api.admin.atlas.status()
setStatus(fresh)
setPath(fresh.path || '')
} catch (err) {
setError(err.message || `Could not ${action}.`)
} finally {
setBusy(false)
}
}
async function savePath() {
setBusy(true)
setMsg('')
setError('')
try {
const fresh = await api.admin.atlas.setPath(path.trim())
setStatus(fresh)
setPath(fresh.path || '')
setMsg(
fresh.path === ''
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
: fresh.treeReadable
? 'Saved. The tree is readable — import when you are ready.'
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
)
} catch (err) {
setError(err.message || 'Could not save the path.')
} finally {
setBusy(false)
}
}
if (loading) return <Loading />
if (error && !status) return <ErrorState message={error} />
const counts = status?.counts || null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Spawn atlas
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
The bestiary and spawn map on the public site, parsed from the shards own ServUO files.
It refreshes itself on every server start; everything here is for the times you dont want
to wait for one. Nothing on this page touches the sidecar the atlas is shard content, not
shard state, and stays complete while the shard is down.
</p>
</header>
{status?.pending && (
<PendingReview
pending={status.pending}
busy={busy}
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
/>
)}
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
What is loaded
</h3>
<Row label="Imported">
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
</Row>
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
{counts && (
<>
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
<Row label="Regions / landmarks">
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
</Row>
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
</>
)}
<Row label="Tree readable">
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
</Row>
<Row label="Tree changed since import">
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
</Row>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
ServUO tree
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Where the website reads the shards spawn files from the same host, a bind mount or a
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
mount can move without a redeploy. Leave it blank to turn the atlas off.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<input
className="input"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/srv/servuo"
style={{ flex: '1 1 320px', minWidth: 0 }}
/>
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
Save path
</button>
</div>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
Re-import
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Applies a map change without restarting. An unchanged tree costs nothing the source files
are hashed first and skipped when they match. A refresh that would remove a facet still
comes back here for approval rather than being applied.
</p>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy || !status?.configured}
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
>
{busy ? 'Working…' : 'Import now'}
</button>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
Re-import even if the tree is unchanged
</label>
</div>
</section>
{(msg || error) && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{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>
)}
</div>
)
}

View File

@@ -1,276 +0,0 @@
import { useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// The operator's Team-forum controls (TEAMS.md §5.5, plus phase 5's edit window),
// and the acknowledgement.
//
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
// same reason EmailDelivery is its own: one of these settings has a server-side
// PRECONDITION and a confirmation flow, and a control with a precondition inside a
// generic list of key/value inputs is one whose behaviour nobody reading that list
// would predict.
//
// **The checkbox below is not the gate.** The server rejects `teams_forum_images =
// 'uploads'` with 400 unless the same request carries the acknowledgement version,
// and it does so whether or not this dialog was ever rendered. What is here is how
// the gate is PRESENTED — the wording an operator agrees to, and the recording of
// which version they agreed to.
// §5.5.5(a). Rendered beneath the selector at ALL times, in every mode: it
// explains what the setting is, which is a different job from the confirmation.
const HELP_TEXT = [
'Image uploads are disabled by default.',
'Enabling uploads allows users to store files on infrastructure that you control.',
'By enabling this feature, you acknowledge that you are responsible for:',
]
const HELP_BULLETS = [
'Moderating uploaded content',
'Managing storage and backups',
'Complying with applicable laws and regulations',
'Establishing policies for your community',
]
const HELP_TAIL = [
'Runic Gateway does not provide hosted storage or content moderation services. All uploaded content'
+ ' is stored on your own infrastructure.',
// Addition 1 — the reassuring counterpart, and the reason the attribution table
// in §5.5.4 exists at all.
'Uploads are attributed to the account that made them, and your staff can remove them at any time.',
// Addition 3 — the blast radius. "Users" is doing a lot of work: forum access is
// not the same as game membership, so this genuinely surprises.
'Anyone with access to a team forum can upload, including members granted access manually who have'
+ ' no linked game account.',
]
// §5.5.2's non-blocking advisory for `remote`. Not an acknowledgement — nothing is
// stored in that mode — but the operator's server is still doing the displaying.
const REMOTE_ADVISORY = 'Images hosted elsewhere are loaded by each visitors browser directly from the'
+ ' site hosting them. That site can see your visitors IP addresses, and you do not control whether'
+ ' the image changes or disappears.'
// §5.5.5(b). Shown only when changing the mode TO uploads.
const DIALOG_CHECKS = [
'I understand that uploaded files will be stored on infrastructure that I control.',
'I understand that I am responsible for community moderation policies on this installation.',
]
// Addition 2 — the expectation gap most likely to bite. An operator who turns
// uploads off because of a problem will assume the problem goes with it.
const DIALOG_TAIL = 'Disabling uploads later stops new files being accepted. It does not delete files'
+ ' already uploaded — remove those from the forum moderation tools.'
const MODES = [
{ value: 'disabled', label: 'Disabled — image URLs stay plain links' },
{ value: 'remote', label: 'Remote — images hosted elsewhere are shown' },
{ value: 'uploads', label: 'Uploads — members may upload images to this server' },
]
export default function TeamForumSettings() {
const { refresh: refreshSite } = useSite()
const [state, setState] = useState(null)
const [enabled, setEnabled] = useState(false)
const [mode, setMode] = useState('disabled')
const [editWindow, setEditWindow] = useState('15')
const [dialog, setDialog] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
const load = async () => {
try {
const s = await api.admin.teamForumSettings()
setState(s)
setEnabled(s.enabled)
setMode(s.imageMode)
setEditWindow(String(s.editWindowMinutes ?? 15))
} catch {
setError('Could not load forum settings.')
}
}
useEffect(() => { load() }, [])
if (!state) return null
const stale = state.acknowledgement?.stale
async function persist(next, acknowledge) {
setBusy(true)
setError('')
try {
await api.admin.updateSettings({
teams_forums_enabled: next.enabled ? '1' : '0',
teams_forum_images: next.mode,
teams_forum_edit_window_minutes: String(next.editWindow),
...(acknowledge ? { acknowledge } : {}),
})
setSaved(true)
await load()
await refreshSite()
} catch (err) {
setError(err.message || 'Could not save forum settings.')
} finally {
setBusy(false)
}
}
// Moving TO uploads asks first; every other change saves directly. A stale
// acknowledgement also routes through the dialog, because re-acknowledging is
// the only thing that unfreezes these settings.
function save() {
setSaved(false)
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
setDialog({ enabled, mode, editWindow })
return
}
if (stale) {
setDialog({ enabled, mode, editWindow })
return
}
persist({ enabled, mode, editWindow })
}
return (
<section style={{ marginTop: 34, maxWidth: 620 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Team forums</h2>
{stale && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#e0b877', margin: '0 0 12px' }}>
The image-upload notice has changed since it was accepted
{state.acknowledgement.acknowledgedBy ? ` by ${state.acknowledgement.acknowledgedBy}` : ''}.
Uploads keep working, but no forum setting can be saved until it is acknowledged again.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => { setEnabled(e.target.checked); setSaved(false) }}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enable Team forums</span>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Off by default. Switching forums off hides them completely every forum route answers not
found but deletes nothing: threads, posts, access grants and notification preferences all
survive and come back exactly as they were.
</span>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Images in forum posts</span>
<select value={mode} onChange={(e) => { setMode(e.target.value); setSaved(false) }} className="select">
{MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</label>
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Post edit window (minutes)</span>
<input
type="number"
className="input"
min={0}
max={state.editWindowMax ?? 1440}
value={editWindow}
onChange={(e) => { setEditWindow(e.target.value); setSaved(false) }}
style={{ maxWidth: 120 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
How long an author may edit their own post after writing it. Staff are not bound by it and
may edit at any time. Set it to 0 to make posts permanent once written a bound of some
kind is what stops a post being rewritten out from under someone quoting it, or under a
moderator about to act on a report.
</span>
</label>
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
<ul style={{ margin: '0 0 6px 18px' }}>
{HELP_BULLETS.map((b) => <li key={b}>{b}</li>)}
</ul>
{HELP_TAIL.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
{mode !== 'disabled' && (
<p style={{ margin: '0 0 6px', color: '#e0b877' }}>{REMOTE_ADVISORY}</p>
)}
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 12, alignItems: 'center' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save forum settings'}
</button>
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
{dialog && (
<UploadsDialog
version={state.acknowledgement.version}
onCancel={() => {
setDialog(null)
setMode(state.imageMode)
setEnabled(state.enabled)
setEditWindow(String(state.editWindowMinutes ?? 15))
}}
onConfirm={async (version) => {
setDialog(null)
await persist(dialog, version)
}}
/>
)}
</section>
)
}
/**
* Two checkboxes, one recorded acknowledgement.
*
* `Enable uploads` stays disabled until both are ticked, but the request carries a
* single version and the stored value is the text VERSION. Recording two booleans
* would add nothing — there is no reachable state where an operator consented to
* one clause and not the other and proceeded anyway — while the version answers
* the question that actually matters later: which text did they agree to?
*/
function UploadsDialog({ version, onCancel, onConfirm }) {
const [checks, setChecks] = useState(DIALOG_CHECKS.map(() => false))
const all = checks.every(Boolean)
return (
<div
role="dialog"
aria-modal="true"
aria-label="Enable image uploads"
style={{
marginTop: 14, padding: 14, border: '1px solid #e0b877', borderRadius: 6,
}}
>
<p className="sans" style={{ margin: '0 0 8px', fontWeight: 600 }}>
Image uploads are currently disabled.
</p>
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.88rem' }}>
Enabling uploads will allow users to store files on your server.
</p>
{DIALOG_CHECKS.map((text, i) => (
<label key={text} className="sans" style={{ display: 'block', fontSize: '0.85rem', marginBottom: 6 }}>
<input
type="checkbox"
checked={checks[i]}
onChange={(e) => setChecks((c) => c.map((v, j) => (j === i ? e.target.checked : v)))}
style={{ marginRight: 8 }}
/>
{text}
</label>
))}
<p className="sans dim" style={{ margin: '10px 0', fontSize: '0.8rem' }}>{DIALOG_TAIL}</p>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={!all}
onClick={() => onConfirm(version)}
>
Enable uploads
</button>
</div>
</div>
)
}

View File

@@ -1,292 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import {
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
setChannel, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
} from '../../../lib/teamIntegrations.js'
// The Team notification bridge (TEAMS.md §7.2, phase 8).
//
// Named for the TEAM concern rather than for Discord, and placed under Teams
// rather than in the Discord Bot panel, because phase 10 replaces "Discord" here
// with whatever the capability registry declares. What changes then should be
// what fills this panel, not where an operator goes to find it. Nothing below
// hardcodes the word except the heading the server sends as `platform`.
//
// **The checkbox in the dialog is not the gate.** The server refuses to enable a
// row carrying `team.forum.post` or `team.announcement` without the
// acknowledgement, 422, whether or not this dialog was ever rendered — the same
// division TeamForumSettings draws for image uploads. What is here is how the
// gate is PRESENTED: the sentence an operator agrees to, and the fact that
// agreeing is a deliberate act rather than a checkbox they tab past.
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
const ACK_TEXT = [
'Forum posts and announcements are visible only to a Teams members. This site cannot see who can'
+ ' read a channel on another platform, so it cannot check that for you.',
'By enabling these events you confirm that the destination channel is restricted to the members of'
+ ' the Team whose posts it will carry.',
]
export default function TeamIntegrations() {
const [config, setConfig] = useState(null)
const [teams, setTeams] = useState([])
const [draft, setDraft] = useState(null)
const [dialog, setDialog] = useState(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setError('')
try {
const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()])
setConfig(cfg)
setTeams((teamList.teams || []).filter((t) => t.status === 'active'))
} catch (err) {
// A moderator never reaches this panel — the admin nav does not render it —
// so a 403 here means the role changed underneath an open tab rather than a
// routing mistake, and saying so beats "could not load".
setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.'))
}
}, [])
useEffect(() => { load() }, [load])
if (!config) {
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Notification bridge</h2>
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
const membersOnlyIds = membersOnlyIdsOf(config.events)
const { hasDefault, teams: available } = availableTargets(config.rows, teams)
async function persist(next) {
setBusy(true)
setError('')
setNotice('')
try {
await api.admin.saveTeamIntegration({
teamId: next.teamId,
events: next.events,
channelRef: next.channelRef.trim() || null,
enabled: next.enabled,
membersAck: next.membersAck,
})
setDraft(null)
setDialog(null)
setNotice('Saved.')
await load()
} catch (err) {
setError(err.message || 'Could not save.')
setDialog(null)
} finally {
setBusy(false)
}
}
// Enabling members-only events without a standing acknowledgement asks first.
// Everything else — disabling, editing a channel, adding a roster event — saves
// straight through.
function save() {
if (!draft) return
if (needsAcknowledgement(draft, membersOnlyIds)) {
setDialog(draft)
return
}
persist(draft)
}
async function remove(row) {
setBusy(true)
setError('')
try {
await api.admin.deleteTeamIntegration(row.team_id ?? null)
setNotice('Removed.')
await load()
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Notification bridge</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
override it for individual Teams. A message is sent once and not retried the bridge is a
courtesy, and nothing on the site depends on it arriving.
</p>
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
{config.rows.length === 0 && !draft && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured no Team events leave the site.</p>
)}
{config.rows.length > 0 && (
<div className="panel-flat" style={{ overflowX: 'auto' }}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Applies to</th>
<th className="adm-th">Events</th>
<th className="adm-th">Channel</th>
<th className="adm-th">State</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={rowKey(row)}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{appliesToLabel(row)}
{isDefaultRow(row) && <span className="dim"> (default)</span>}
</td>
<td className="adm-td">
{row.events.length === 0
? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')}
</td>
<td className="adm-td dim">{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="adm-td">
{row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn btn-ghost btn-sq" style={{ marginLeft: 8 }} disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!draft && (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14 }}>
{!hasDefault && (
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(null))}>
Set a default for all Teams
</button>
)}
{available.length > 0 && (
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(available[0].id))}>
Add a per-Team override
</button>
)}
</div>
)}
{draft && (
<div style={{ marginTop: 18, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Applies to</span>
<select
className="select"
value={draft.teamId === null ? 'default' : String(draft.teamId)}
onChange={(e) => setDraft({ ...draft, teamId: e.target.value === 'default' ? null : Number(e.target.value) })}
>
<option value="default">All Teams (default)</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.display_name_override || t.name}</option>
))}
</select>
</label>
<span className="field-label">Events to send</span>
{config.events.map((event) => (
<label key={event.id} style={{ display: 'block', marginTop: 6 }}>
<input
type="checkbox"
checked={draft.events.includes(event.id)}
onChange={() => setDraft((d) => toggleEvent(d, event.id))}
style={{ marginRight: 8 }}
/>
<span className="sans" style={{ fontSize: '0.82rem' }}>{eventLabel(event.id)}</span>
{event.membersOnly && (
<span className="dim sans" style={{ fontSize: '0.72rem', marginLeft: 8 }}>members-only content</span>
)}
</label>
))}
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Channel id</span>
<input
className="input"
value={draft.channelRef}
// Changing the channel drops a standing acknowledgement in the SAME
// place the server does. Leaving the tick showing while the server
// has already decided to clear it would let an operator repoint a row
// at a public channel and believe the confirmation still covered it.
onChange={(e) => setDraft((d) => setChannel(d, e.target.value))}
placeholder="1024839201048392010"
style={{ maxWidth: 280 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Right-click a channel in {config.platform} and copy its id. Changing it asks you to confirm
the new channels audience again.
</span>
</label>
<label style={{ display: 'block', marginTop: 14 }}>
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enabled</span>
</label>
{draft.membersAck && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
You have confirmed this channel is restricted to the Teams members.{' '}
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft({ ...draft, membersAck: false })}>
Withdraw
</button>
</p>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
</div>
</div>
)}
{dialog && (
<div style={{ marginTop: 18, border: '1px solid #e0b070', padding: 16, borderRadius: 'var(--radius-input)' }}>
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destinations audience</h3>
{ACK_TEXT.map((line) => (
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
))}
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => persist({ ...dialog, membersAck: true })}
>
I confirm the channel is members-only
</button>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
</div>
)}
</section>
)
}

View File

@@ -1,273 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import {
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
parseStaffRoles, formatStaffRoles, statusSummary,
} from '../../../lib/teamVoice.js'
// Team voice channels (TEAMS.md §7.3, phase 9).
//
// Named for the Team concern and placed under Teams beside the notification
// bridge, for the reason that panel gives: phase 10 replaces "Discord" with
// whatever the capability registry declares, and what should change then is what
// fills this panel rather than where an operator goes to find it.
//
// **The preflight is the first thing on the page, not a diagnostic.** §7.3
// assumed the bot could manage channels and roles; nothing in this project has
// ever checked, because the operator invites the bot by hand and no invite URL
// with a permission integer exists anywhere in the tree. An operator whose bot
// lacks Manage Roles otherwise has a screen full of controls that cannot work,
// and finds out one Team at a time from a column of identical errors.
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
export default function TeamVoice() {
const [config, setConfig] = useState(null)
const [draft, setDraft] = useState(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setError('')
try {
const cfg = await api.admin.teamVoice()
setConfig(cfg)
setDraft({
enabled: cfg.settings.enabled,
minMembers: cfg.settings.minMembers,
graceDays: cfg.settings.graceDays,
staffRoles: formatStaffRoles(cfg.settings.staffRoles),
})
} catch (err) {
// A moderator never reaches this panel — the admin nav does not render it —
// so a 403 means the role changed underneath an open tab.
setError(err.status === 403
? 'Only an admin can configure Team voice channels.'
: (err.message || 'Could not load the voice configuration.'))
}
}, [])
useEffect(() => { load() }, [load])
if (!config || !draft) {
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Voice channels</h2>
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
const blocked = enableBlockedReason(config.preflight)
const headroom = roleHeadroom(config.preflight)
async function save() {
const { roles, invalid } = parseStaffRoles(draft.staffRoles)
if (invalid.length > 0) {
setError(`Not a role id: ${invalid.join(', ')}. Copy role ids from Discord with Developer Mode on.`)
return
}
setBusy(true)
setError('')
setNotice('')
try {
await api.admin.saveTeamVoice({
enabled: draft.enabled,
minMembers: Number(draft.minMembers),
graceDays: Number(draft.graceDays),
staffRoles: roles,
})
setNotice('Saved.')
await load()
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
async function runPass() {
setBusy(true)
setError('')
setNotice('')
try {
const result = await api.admin.teamVoicePass()
// A pass that refused says why, and that is the useful answer far more often
// than a count is — "stale projection" and "synced 0" look identical in a
// summary and mean completely different things.
setNotice(result.ran
? `Synced ${result.synced}, created ${result.created}, scheduled ${result.scheduled}, removed ${result.removed}, failed ${result.failed}.`
: `Nothing was done: ${result.reason}`)
await load()
} catch (err) {
setError(err.message || 'Could not run a pass.')
} finally {
setBusy(false)
}
}
async function remove(row) {
setBusy(true)
setError('')
try {
await api.admin.removeTeamVoice(row.teamId)
setNotice('Removed.')
await load()
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Voice channels</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
Give each Team a {config.platform} voice channel of its own. Access is granted with a role per
Team, so members of a Team can see and join their channel and nobody else can. Members need a
linked {config.platform} account and must be in the guild.
</p>
{blocked && (
<p className="sans" style={{ color: '#e0b070', fontSize: '0.82rem' }}>
{blocked} Voice channels cannot be switched on until that is fixed.
</p>
)}
{headroom && (
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
{headroom.used} of {headroom.cap} {config.platform} roles used in this guild
{headroom.exhausted
? ' — no room for another Team.'
: headroom.tight
? ` — room for about ${headroom.free} more Teams.`
: '.'}
</p>
)}
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
<p className="sans" style={{ fontSize: '0.8rem' }}>{statusSummary(config.settings, config.rows)}</p>
<div style={{ marginTop: 14, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
<label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}>
<input
type="checkbox"
checked={draft.enabled}
disabled={busy || (!!blocked && !draft.enabled)}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
/>
{' '}Provision voice channels for Teams
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Minimum members</span>
<input
className="input"
type="number"
min="1"
max="10000"
value={draft.minMembers}
disabled={busy}
onChange={(e) => setDraft({ ...draft, minMembers: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Every active member counts, whether or not they have linked an account.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Grace window (days)</span>
<input
className="input"
type="number"
min="0"
max="90"
value={draft.graceDays}
disabled={busy}
onChange={(e) => setDraft({ ...draft, graceDays: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the
window keeps the same channel; zero removes it on the next pass.
</span>
</label>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Staff roles</span>
<input
className="input"
type="text"
value={draft.staffRoles}
disabled={busy}
placeholder="role id, role id"
onChange={(e) => setDraft({ ...draft, staffRoles: e.target.value })}
/>
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
Roles that can see and join every Teams channel. Guild administrators already can, so this
is for staff who are not administrators. Leave empty if there are none.
</span>
</label>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={runPass}>Sync now</button>
</div>
</div>
{config.rows.length > 0 && (
<div className="panel-flat" style={{ marginTop: 18, overflowX: 'auto' }}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Team</th>
<th className="adm-th">Members</th>
<th className="adm-th">Channel</th>
<th className="adm-th">State</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={row.teamId}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.teamName}</td>
<td className="adm-td">{row.memberCount}</td>
<td className="adm-td dim">
{row.channelRef || <span className="dim">none</span>}
</td>
<td className="adm-td">
{stateLabel(row.state)}
{removalCountdown(row) && (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
{removalCountdown(row)}
</span>
)}
{row.lastError && (
<span style={{ display: 'block', color: '#d98b84', fontSize: '0.78rem', marginTop: 3 }}>
{row.lastError}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{config.lastPass && config.lastPass.at && (
<p className="sans dim" style={{ fontSize: '0.74rem', marginTop: 10 }}>
Last pass {new Date(config.lastPass.at).toLocaleString()}
{config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}
</p>
)}
</section>
)
}

View File

@@ -1,490 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import {
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
} from '../../../lib/teamAdmin.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import TeamIntegrations from './TeamIntegrations.jsx'
import TeamVoice from './TeamVoice.jsx'
// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
//
// Three panels, in the order an operator needs them:
//
// 1. **Sync state**, verbatim, including the last error. The screen's first job
// is to make "the shard has no Teams" and "core has not been able to ask for
// two hours" impossible to confuse — they render almost identically
// otherwise, and one is fine while the other is an outage.
// 2. **The review queue** — Teams auto-hidden because their name matched the
// impersonation list, each showing which term matched.
// 3. **The approval queue** — what moderators have asked to publish.
//
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
// plain JS and has tests; this file renders it.
// Tones map onto the badge modifiers the rest of the admin panel already uses,
// rather than onto inline colours. `.badge` on its own carries no border or
// background — those live on the modifier — so a bare `className="badge"` with an
// inline `borderColor` renders borderless, which is what this screen used to do.
const TONE_BADGE = { ok: 'badge-pub', warn: 'badge-moderator', bad: 'badge-ban', idle: 'badge-draft' }
// The same three tones as text, for the places a badge would be wrong (a verbatim
// error line). House palette — the values every other admin view uses.
const TONE_TEXT = { ok: '#7fd0a4', warn: '#e0b070', bad: '#d98b84', idle: 'var(--muted)' }
const PANEL = { padding: 22, marginBottom: 22 }
const HEADING = { margin: '0 0 12px', fontSize: '1.2rem', color: 'var(--head)' }
const KV_VALUE = { margin: 0, fontSize: '0.88rem', color: 'var(--text)' }
const SCROLLER = { overflowX: 'auto' }
const BLURB = { margin: '0 0 14px', color: 'var(--muted)', fontSize: '0.85rem', lineHeight: 1.6 }
function Pill({ tone, children }) {
return <span className={`badge ${TONE_BADGE[tone] || 'badge-draft'}`}>{children}</span>
}
// ── Sync state ─────────────────────────────────────────────────────────────
function SyncPanel({ sync, syncState, onResync, busy }) {
const freshness = freshnessOf(sync)
return (
<section className="panel" style={PANEL}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Sync</h2>
<Pill tone={freshness.tone}>{freshness.label}</Pill>
<button
type="button"
className="btn btn-ghost btn-sq"
onClick={onResync}
disabled={busy || !sync.configured}
>
{busy ? 'Resyncing…' : 'Resync now'}
</button>
</div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.85rem' }}>{freshness.detail}</p>
{syncState && (
<dl
style={{
display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', gap: '9px 20px',
margin: '16px 0 0', alignItems: 'baseline',
}}
>
<dt className="field-label" style={{ margin: 0 }}>Module</dt>
<dd className="sans" style={KV_VALUE}>{syncState.moduleId}</dd>
<dt className="field-label" style={{ margin: 0 }}>Last attempt</dt>
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
<dt className="field-label" style={{ margin: 0 }}>Last success</dt>
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
<dt className="field-label" style={{ margin: 0 }}>Consecutive failures</dt>
<dd className="sans" style={KV_VALUE}>{syncState.consecutiveFailures}</dd>
{syncState.lastError && (
<>
{/* Verbatim. An operator debugging a stale projection needs what the
provider actually said, not a friendlier paraphrase of it. */}
<dt className="field-label" style={{ margin: 0 }}>Last error</dt>
<dd className="sans" style={{ ...KV_VALUE, color: TONE_TEXT.bad }}>{syncState.lastError}</dd>
</>
)}
{syncState.pendingEmptySince && (
<>
<dt className="field-label" style={{ margin: 0 }}>Empty answer held</dt>
<dd className="sans" style={KV_VALUE}>
since {dateTime(syncState.pendingEmptySince)} an authoritative but empty list is
applied only if the next answer agrees.
</dd>
</>
)}
</dl>
)}
</section>
)
}
// ── The reserved-name review queue ─────────────────────────────────────────
function ReviewQueue({ rows, role, onAct, busy }) {
if (!rows.length) return null
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Names to review</h2>
<p className="sans" style={BLURB}>
These Teams are hidden from every public surface because their name matched a reserved term.
They work normally for their own members. {GATED_NOTE}
</p>
<div className="panel-flat" style={SCROLLER}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Matched</th>
<th className="adm-th">Members</th>
<th className="adm-th">Created</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.name}</td>
<td className="adm-td"><Pill tone="bad">{row.hidden_term}</Pill></td>
<td className="adm-td">{row.member_count}</td>
<td className="adm-td dim">{dateTime(row.created_at)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onAct(row.id, 'unhide')}
>
{gateLabelFor(role, 'Publish')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
// ── The approval queue ─────────────────────────────────────────────────────
function RequestQueue({ rows, role, onDecide, busy }) {
if (!rows.length) return null
const canDecide = role === 'admin'
return (
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Awaiting approval</h2>
<p className="sans" style={BLURB}>
{canDecide
? 'Approving publishes the name; rejecting keeps the record and changes nothing.'
: 'Only an admin can decide these. Your own requests stay here until one does.'}
</p>
<div className="panel-flat" style={SCROLLER}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Request</th>
<th className="adm-th">Requested</th>
<th className="adm-th">Reason</th>
{canDecide && <th className="adm-th" />}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{describeRequest(row)}</td>
<td className="adm-td dim">{dateTime(row.requested_at)}</td>
<td className="adm-td dim">{row.reason ? `${row.reason}` : '—'}</td>
{canDecide && (
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onDecide(row.id, 'approved')}
>
Approve
</button>
<button
type="button"
className="btn btn-ghost btn-sq"
style={{ marginLeft: 8 }}
disabled={busy}
onClick={() => onDecide(row.id, 'rejected')}
>
Reject
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
// ── One Team ───────────────────────────────────────────────────────────────
function TeamRow({ team, role, onAct, busy, onLedger }) {
const status = statusOf(team)
return (
<tr>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{team.displayName}
{team.displayNameOverride && (
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
shown instead of {team.name}
</div>
)}
</td>
<td className="adm-td"><Pill tone={status.tone}>{status.label}</Pill></td>
<td className="adm-td">{team.memberCount}</td>
<td className="adm-td">{team.linkedCount}</td>
<td className="adm-td">{team.onlineCount}</td>
<td className="adm-td dim">{dateTime(team.rosterSyncedAt) || 'never'}</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{team.status === 'active' && (team.hidden
? (
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => onAct(team.id, 'unhide')}
>
{gateLabelFor(role, 'Publish')}
</button>
)
: (
<button
type="button"
className="btn btn-ghost btn-sq"
disabled={busy}
onClick={() => onAct(team.id, 'hide')}
>
Hide
</button>
))}
<button
type="button"
className="btn btn-ghost btn-sq"
onClick={() => onLedger(team)}
style={{ marginLeft: 8 }}
>
Forum log
</button>
</td>
</tr>
)
}
/**
* One Team's forum moderation ledger (TEAMS.md §5.3).
*
* The route and the API method have existed since phase 4 and nothing rendered
* them, which made the ledger a table only a DB client could read. The column
* that earns the screen is `actorRole`: it records WHICH authority was exercised,
* so a leader's ordinary housekeeping stays distinguishable from a staff
* intervention after the fact.
*
* **This is deliberately not merged with the site's mod_actions/appeals pair.**
* That one is Discord-sanction-shaped and bot-owned; routing a guild leader
* locking a thread through it would make ordinary housekeeping an appealable
* sanction with a reversal path into the bot. Every STAFF-exercised action here
* additionally writes activity_log, so the site's accountability trail sees it —
* the two are cross-referenced, not merged.
*/
function ForumLedger({ team, onClose }) {
const [rows, setRows] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let active = true
api.admin.teamForumModeration(team.id)
// `{ entries }`, and the rows are the ledger table's own snake_case
// columns — this endpoint serves them unmapped, unlike the Team payloads
// above it. Reading them as they are, rather than accepting three possible
// shapes, is what makes a change to that endpoint fail here instead of
// rendering an empty table.
.then((res) => { if (active) setRows(res.entries) })
.catch((err) => { if (active) setError(err.message || 'Could not load the forum log.') })
return () => { active = false }
}, [team.id])
return (
<section className="panel" style={PANEL}>
<header
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: 14, flexWrap: 'wrap', marginBottom: 12,
}}
>
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Forum log {team.displayName}</h2>
<button type="button" className="btn btn-ghost btn-sq" onClick={onClose}>Close</button>
</header>
{error && <ErrorState message={error} />}
{!rows && !error && <Loading />}
{rows && rows.length === 0 && (
<p className="sans" style={{ ...BLURB, margin: 0 }}>Nothing has been moderated in this forum.</p>
)}
{rows && rows.length > 0 && (
<div className="panel-flat" style={SCROLLER}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">When</th>
<th className="adm-th">Action</th>
<th className="adm-th">Target</th>
<th className="adm-th">By</th>
<th className="adm-th">As</th>
<th className="adm-th">Reason</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="adm-td dim">{dateTime(r.created_at)}</td>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
<td className="adm-td">{r.actor_username || '—'}</td>
<td className="adm-td">
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
</td>
<td className="adm-td dim">{r.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
export default function TeamsAdmin() {
const { user } = useAuth()
const role = user ? user.role : null
const [data, setData] = useState(null)
const [review, setReview] = useState([])
const [requests, setRequests] = useState([])
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [ledgerTeam, setLedgerTeam] = useState(null)
const load = useCallback(async () => {
setError('')
try {
const [teams, reviewQueue, requestQueue] = await Promise.all([
api.admin.listTeams(),
api.admin.teamReviewQueue(),
api.admin.teamRequests('pending'),
])
setData(teams)
setReview(reviewQueue.teams || [])
setRequests(requestQueue.requests || [])
} catch (err) {
setError(err.message || 'Could not load Teams.')
}
}, [])
useEffect(() => { load() }, [load])
async function run(fn, pendingMessage) {
setBusy(true)
setNotice('')
setError('')
try {
const result = await fn()
// The server decides whether an action applied or was filed, from the
// caller's live role. Saying so plainly is what stops a moderator thinking
// nothing happened.
if (result && result.pending) setNotice(pendingMessage)
await load()
} catch (err) {
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
const act = (id, action) => run(
() => (action === 'hide' ? api.admin.hideTeam(id) : api.admin.unhideTeam(id)),
'Filed for approval. Nothing has changed publicly until an admin approves it.',
)
const decide = (id, status) => run(
() => api.admin.decideTeamRequest(id, status),
'',
)
const resync = () => run(async () => {
const result = await api.admin.resyncTeams()
// A refusal is the normal, designed outcome when the provider cannot answer,
// so it is reported as a result rather than thrown as an error.
if (!result.ok) setError(`Resync refused: ${result.reason}. Nothing was changed.`)
else if (result.quarantined) {
setNotice('The provider answered with an empty list. It is being held for confirmation, not applied.')
}
return null
}, '')
if (error && !data) return <ErrorState message={error} />
if (!data) return <Loading />
return (
<div>
{/* No page <h1>: AdminLayout's topbar already titles the page, as it does for
every other admin screen. This one used to render its own, which is why
"Teams" appeared twice — once in Cinzel in the bar and once in the body
in whatever the UA picked for an unstyled heading. */}
{error && <ErrorState message={error} />}
{notice && (
<div className="note sans" style={{ fontSize: '0.85rem', marginBottom: 22 }}>{notice}</div>
)}
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
{/* Admin-only, matching the server (§7.2). Rendered for a moderator it would
be a panel every action in fails 403 — the role gate is the server's, and
this is only how the screen agrees with it. */}
{role === 'admin' && <TeamIntegrations />}
{role === 'admin' && <TeamVoice />}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>All Teams</h2>
{!data.teams.length && (
<p className="sans" style={{ ...BLURB, margin: 0 }}>
{data.configured
? 'No Teams in the projection yet.'
: 'No installed module supplies Teams, so there is nothing to show.'}
</p>
)}
{data.teams.length > 0 && (
<div className="panel-flat" style={SCROLLER}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Status</th>
<th className="adm-th">Members</th>
<th className="adm-th">Linked</th>
<th className="adm-th">Online</th>
<th className="adm-th">Roster confirmed</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{data.teams.map((team) => (
<TeamRow
key={team.id}
team={team}
role={role}
onAct={act}
busy={busy}
onLedger={setLedgerTeam}
/>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
)
}
export { leadershipOf }

View File

@@ -1,16 +1,17 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, 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 } from '../../../lib/format.js'
import { dateTime, ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import Slot from '../../../modules/Slot.jsx'
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
// 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.
// 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.
const ROLE_BADGE = {
admin: 'badge-admin',
@@ -27,6 +28,114 @@ 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 (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// 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 (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// 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 (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this users accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
// 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 }) {
@@ -135,8 +244,25 @@ function SecurityAdmin({ userId }) {
)
}
function ShardSections({ scope }) {
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}
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 <Loading />
@@ -170,11 +296,7 @@ export default function UserDetail() {
</div>
<SecurityAdmin userId={id} />
{/* Whatever the installed module has to say about this user, or nothing
at all — core filled this with its own UO sections until Phase 3 slice
3, and now nothing does unless a module is installed
(MODULE_API.md §3.7). */}
<Slot name="admin.users.detail" userId={id} />
<ShardSections scope={scope} />
</section>
)
}

Some files were not shown because too many files have changed in this diff Show More