Compare commits
1 Commits
2e9ed50e21
...
spike/modu
| Author | SHA1 | Date | |
|---|---|---|---|
| bf470c7658 |
@@ -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
|
||||
|
||||
71
.env.example
71
.env.example
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
7
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
303
README.md
@@ -8,19 +8,16 @@
|
||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||
[](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 · <module>_*")]
|
||||
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
|
||||
|
||||
%% ---------- Module side ----------
|
||||
subgraph modside["modules/<id>/ — 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/<tier>/<prefix>"| router
|
||||
loader -->|"require() + register(ctx, api)"| modsrv
|
||||
modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
|
||||
modsrv <--> game
|
||||
browser -->|"<script type=module> 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 listener’s 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)
|
||||
})
|
||||
@@ -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 Discord’s numeric option types, not the contract’s 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 Discord’s 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')
|
||||
})
|
||||
@@ -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: 'Blackthorn’s 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, 'Blackthorn’s 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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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())}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
293
client/src/components/CharacterSheet.jsx
Normal file
293
client/src/components/CharacterSheet.jsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
79
client/src/components/CharacterStats.jsx
Normal file
79
client/src/components/CharacterStats.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
client/src/components/CreateGameAccountForm.jsx
Normal file
69
client/src/components/CreateGameAccountForm.jsx
Normal 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 3–30 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 you’ll 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>
|
||||
)
|
||||
}
|
||||
231
client/src/components/GameAccounts.jsx
Normal file
231
client/src/components/GameAccounts.jsx
Normal 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 user’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
84
client/src/components/PlayersOnline.jsx
Normal file
84
client/src/components/PlayersOnline.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
88
client/src/components/ShardAccountActions.jsx
Normal file
88
client/src/components/ShardAccountActions.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) => <> · {link}</>} />
|
||||
·
|
||||
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Shard Status
|
||||
</Link>
|
||||
·
|
||||
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
|
||||
Admin
|
||||
|
||||
@@ -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}
|
||||
|
||||
41
client/src/components/VendorSales.jsx
Normal file
41
client/src/components/VendorSales.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
31
client/src/data/cityCrests.js
Normal file
31
client/src/data/cityCrests.js
Normal 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) }
|
||||
}
|
||||
72
client/src/data/regionBuckets.js
Normal file
72
client/src/data/regionBuckets.js
Normal 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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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 template’s 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>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -1,764 +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',
|
||||
note: 'Note',
|
||||
}
|
||||
|
||||
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`
|
||||
default:
|
||||
return logKindWord(line?.kind)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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`
|
||||
}
|
||||
128
client/src/lib/shardEvents.js
Normal file
128
client/src/lib/shardEvents.js
Normal 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, ' ')
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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 moderator’s 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'})`,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// "<script>" become a real tag the next pass then removes, which is a
|
||||
// different string from the one the author wrote.
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
// `&` last of all, or "&lt;" would decode two steps into "<".
|
||||
.replace(/&/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(' · ')
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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 bot’s 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.`
|
||||
}
|
||||
58
client/src/lib/useShardFeatures.js
Normal file
58
client/src/lib/useShardFeatures.js
Normal 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)
|
||||
}
|
||||
54
client/src/lib/useShardFeed.js
Normal file
54
client/src/lib/useShardFeed.js
Normal 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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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’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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
29
client/src/routes/admin/views/AdminCharacter.jsx
Normal file
29
client/src/routes/admin/views/AdminCharacter.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
client/src/routes/admin/views/AdminCharacters.jsx
Normal file
18
client/src/routes/admin/views/AdminCharacters.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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’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)' }
|
||||
@@ -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',
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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’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’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>
|
||||
)
|
||||
}
|
||||
@@ -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 person’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 & 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 “degraded” 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 & 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 & 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’s own diagnostic record, newest first — this is what answers “why didn’t phase 3
|
||||
start?” 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}>
|
||||
←
|
||||
</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)}>
|
||||
→
|
||||
</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’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>
|
||||
)
|
||||
}
|
||||
119
client/src/routes/admin/views/HousesAdmin.jsx
Normal file
119
client/src/routes/admin/views/HousesAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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’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’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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 server’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
|
||||
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>
|
||||
)
|
||||
|
||||
248
client/src/routes/admin/views/ShardAdmin.jsx
Normal file
248
client/src/routes/admin/views/ShardAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
291
client/src/routes/admin/views/ShardOps.jsx
Normal file
291
client/src/routes/admin/views/ShardOps.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
325
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
325
client/src/routes/admin/views/ShardVisibility.jsx
Normal 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
|
||||
aren’t visible in game either, so publishing them would disclose something the shard
|
||||
itself doesn’t.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal file
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal 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 shard’s own ServUO files.
|
||||
It refreshes itself on every server start; everything here is for the times you don’t 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 shard’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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 visitor’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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 Team’s 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 channel’s 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 Team’s 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 destination’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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 Team’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 user’s 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 & 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
Reference in New Issue
Block a user