Compare commits
218 Commits
886a504152
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d4cd4adae | |||
| 49a61fdafa | |||
| c208543044 | |||
| 87c4e71025 | |||
| 24a3cd85b3 | |||
| 5168446c53 | |||
| 065bec7ad8 | |||
| e2dad3104f | |||
| 3f90070566 | |||
| 42b40fdec2 | |||
| 12ff201ed5 | |||
| 1d7961e7a2 | |||
| 3a7a08425c | |||
| 4b45eddb5d | |||
| 4d3f574480 | |||
| 2079aaf667 | |||
| 447c9113d3 | |||
| b13ffd584f | |||
| ea3499e70b | |||
| 563199a096 | |||
| 6016b325bb | |||
| fbb4b0bd91 | |||
| c2e4df5b3d | |||
| 6e61146678 | |||
| f5aa32e0ed | |||
| b77e817fb1 | |||
| c4ab8b9b9d | |||
| 47c8b37d45 | |||
| e25e7ade80 | |||
| 3bca112502 | |||
| c43e092248 | |||
| 0f96a372cf | |||
| 68f038f456 | |||
| 963d734dcc | |||
| 48a3e33be4 | |||
| 335d69d122 | |||
| 9619fdf1e1 | |||
| f72c92ffbe | |||
| 61abb3ec89 | |||
| d1d56cf847 | |||
| 11b4368b57 | |||
| 46f43a5fd6 | |||
| aca4d23179 | |||
| cecd72915f | |||
| b1d3b87cd6 | |||
| 13312d7fc3 | |||
| 5fa88baa0a | |||
| b458c1f46f | |||
| 2a56cbf22a | |||
| 686a214979 | |||
| 26c23bd603 | |||
| 0467c71ea1 | |||
| c970caee16 | |||
| 3f7e61af1c | |||
| 128de0ff2e | |||
| fff14848f1 | |||
| ae0d27cf27 | |||
| 763de66ebb | |||
| 5baada08ef | |||
| 16e31de087 | |||
| 57286594e7 | |||
| cbb7339a3a | |||
| 4ac353684a | |||
| e27c368234 | |||
| fb70013adf | |||
| 11fd9821bf | |||
| 7ed2ac9983 | |||
| 5d9d10b245 | |||
| 203ce9c654 | |||
| 8f4aff6946 | |||
| 03631d7d40 | |||
| aa332eda82 | |||
| 1f175786a7 | |||
| cf2666e5bc | |||
| 8fe2e01466 | |||
| bfd844e8fb | |||
| 92631347f9 | |||
| 8b63ffc725 | |||
| 225663d62e | |||
| e0c961c690 | |||
| 3669696532 | |||
| 953d0c25f6 | |||
| 4ad8b2bb0e | |||
| 1433b60d6c | |||
| 1b692bf624 | |||
| 5410e7e0b3 | |||
| c3120ea3da | |||
| a4da1cc438 | |||
| 12df79430f | |||
| ec2b530be7 | |||
| 8bc09d8b53 | |||
| 9b16f39a52 | |||
| 75f4d29e93 | |||
| 9083e4135a | |||
| 732927a6bb | |||
| 50d719cf46 | |||
| b30e82cde2 | |||
| 2cb549e9e5 | |||
| adff20be7b | |||
| 87230c879a | |||
| 0c4eacfa4a | |||
| a99ead4ee4 | |||
| 5bdb6a7e10 | |||
| f7d27f7a06 | |||
| 5b5006c365 | |||
| 91964c8898 | |||
| d667565ae7 | |||
| 1d1350558b | |||
| 7a236cad8b | |||
| f5e6025dcc | |||
| 39d731d87a | |||
| f50541f374 | |||
| b649345484 | |||
| a103e0ce10 | |||
| 0a3f1eb9fa | |||
| a45a3d120a | |||
| e3c999b704 | |||
| e0927bc255 | |||
| fe83c91ba9 | |||
| 291c30f6ff | |||
| 85f563fc16 | |||
| 32ed8e4411 | |||
| 21196466ed | |||
| 39eaae90a8 | |||
| 97f19b4221 | |||
| 6195c76d61 | |||
| bd749d4f1f | |||
| 2892d01b24 | |||
| 7780fb033b | |||
| ec1ca7e794 | |||
| dcf6ef1886 | |||
| 3add0063bf | |||
| f1dda8fe66 | |||
| 4691fd6633 | |||
| 265042eaa5 | |||
| 18815f4c7a | |||
| 15cefe5ea1 | |||
| b517d7b2df | |||
| 78f994955c | |||
| 32a3ff104a | |||
| 42a403ad2e | |||
| 847cfd2d2b | |||
| 02580ebda3 | |||
| 3d6b2e23a7 | |||
| 0a2ccafff6 | |||
| ec0036ce6d | |||
| d765280e28 | |||
| 03534c8db1 | |||
| 5103b74a9d | |||
| c91fd128bf | |||
| 01a559792c | |||
| e50fab241f | |||
| 779a304173 | |||
| c6c0c257dd | |||
| 8771a1cf6c | |||
| 8da658f223 | |||
| bda031566a | |||
| b61a4d6721 | |||
| 1e1a3d67c3 | |||
| 26094459ae | |||
| bfa1db58c4 | |||
| 7c769ea8fd | |||
| f3d084e046 | |||
| a4ef9d676d | |||
| 2801ec8f4d | |||
| 353cce9f26 | |||
| 7b98f1a778 | |||
| 61d6bfaca2 | |||
| 6b1396dd2f | |||
| f30ea66fce | |||
| cd56af3f12 | |||
| f3450686e0 | |||
| a3407ae654 | |||
| 620781b7bc | |||
| f6611231c4 | |||
| a6fd5659c4 | |||
| 068844bfd9 | |||
| 565a7d2c20 | |||
| 3fcc64ab96 | |||
| 8fd0d82580 | |||
| 812b895507 | |||
| 00ad16858a | |||
| 493843241e | |||
| bd53a0b8a4 | |||
| 0e11e28cca | |||
| f7c98b8ba3 | |||
| 8ad892725f | |||
| 1a61cd1638 | |||
| 0dc5af0d8b | |||
| 9b74999610 | |||
| 49b70ee04d | |||
| 1079b3fc05 | |||
| cbe54fcc91 | |||
| 9f9bcc6f6e | |||
| ebfae765d9 | |||
| c075ab981c | |||
| bcdba4ce0a | |||
| e08c0c9736 | |||
| 5fe7032567 | |||
| 4151f7d44e | |||
| 4f1a4902e8 | |||
| 14dfc122ba | |||
| 514bc9d23c | |||
| 60ebacff2c | |||
| 8d5bdc0d6e | |||
| c991a07c8a | |||
| 4c13706958 | |||
| 2306545574 | |||
| 70849f96ee | |||
| 1edef8e6db | |||
| dc90df9fff | |||
| 68126efc0b | |||
| 401db8f75c | |||
| 9b0f2d93d8 | |||
| 12d50fd615 | |||
| 4993470fa2 | |||
| 2f3e8f7df5 | |||
| 68a4e9295b |
@@ -10,5 +10,8 @@ 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
|
||||
|
||||
74
.env.example
74
.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 private Ultima Online shard
|
||||
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
|
||||
BRAND_TAGLINE=an independent game community
|
||||
BRAND_DESCRIPTION=Runic Gateway — an independent game community. 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,6 +56,21 @@ 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).
|
||||
@@ -83,10 +98,14 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# 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).
|
||||
# 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.
|
||||
|
||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
@@ -107,17 +126,32 @@ CLIENT_ORIGIN=http://localhost:5173
|
||||
BOT_INTERNAL_URL=http://bot:4100
|
||||
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||
|
||||
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
|
||||
# website ingests its live event feed and proxies its read queries/commands
|
||||
# (shard status, online players, player-vendor sales, IDOC houses, character
|
||||
# sheets, account linking, town-crier). In production the sidecar + shard run on
|
||||
# a DIFFERENT host from the website, so both URLs are configurable. The
|
||||
# shared-secret auth token is NOT an env var — it is entered in the admin panel
|
||||
# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot
|
||||
# token). These URLs are just defaults; the admin can override them at runtime.
|
||||
UOLINK_BASE_URL=http://127.0.0.1:8080
|
||||
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
|
||||
UOLINK_PROTOCOL=1
|
||||
# ─── 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.
|
||||
|
||||
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
|
||||
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
||||
@@ -131,6 +165,12 @@ UOLINK_PROTOCOL=1
|
||||
# register endpoints on a different host than NTFY_BASE_URL.
|
||||
# NTFY_PUBLISH_TOKEN Optional. The content-free-tickle design needs NO token;
|
||||
# set one only to require auth on backend→ntfy publishes.
|
||||
# NTFY_HOST_PORT Host port the ntfy container publishes :80 on (default
|
||||
# 2586). The public reverse proxy forwards the notification
|
||||
# subdomain to host:NTFY_HOST_PORT — required because the
|
||||
# proxy lives outside the compose network and cannot reach
|
||||
# ntfy any other way. Change only on a host-port conflict.
|
||||
NTFY_BASE_URL=https://ntfy.example.com
|
||||
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
||||
# NTFY_PUBLISH_TOKEN=
|
||||
# NTFY_HOST_PORT=2586
|
||||
|
||||
@@ -28,3 +28,36 @@ 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=
|
||||
|
||||
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
|
||||
|
||||
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
|
||||
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
|
||||
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
|
||||
|
||||
Deterministic ordering: directories before files, each group sorted
|
||||
case-insensitively with the raw name as a tiebreak. Output uses the classic
|
||||
`tree(1)` box-drawing style so the result is stable across runs and platforms.
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
def build(paths):
|
||||
root = {}
|
||||
for p in paths:
|
||||
p = p.strip().replace("\\", "/")
|
||||
if not p:
|
||||
continue
|
||||
node = root
|
||||
for part in p.split("/"):
|
||||
node = node.setdefault(part, {})
|
||||
return root
|
||||
|
||||
|
||||
def render(node, prefix, lines):
|
||||
entries = list(node.items())
|
||||
# directories (non-empty children dict) before files, then case-insensitive name
|
||||
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
|
||||
for i, (name, child) in enumerate(entries):
|
||||
last = i == len(entries) - 1
|
||||
branch = "└── " if last else "├── "
|
||||
suffix = "/" if child else ""
|
||||
lines.append(f"{prefix}{branch}{name}{suffix}")
|
||||
if child:
|
||||
render(child, prefix + (" " if last else "│ "), lines)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
|
||||
except AttributeError:
|
||||
pass
|
||||
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
tree = build(sys.stdin.read().splitlines())
|
||||
lines = [f"{root_label}/"]
|
||||
render(tree, "", lines)
|
||||
sys.stdout.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,8 +1,15 @@
|
||||
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
|
||||
# build or failing test can't reach the deployable branch. Complements
|
||||
# Gate every pull request into `main` or `edge` on a fast, DB-free check suite so
|
||||
# a broken build or failing test can't reach either integration branch. Complements
|
||||
# build-images.yml, which runs only AFTER merge (on push to main) to publish
|
||||
# images — this one runs BEFORE merge.
|
||||
#
|
||||
# `edge` is listed as well as `main` because long workstreams land phase by phase
|
||||
# on `edge` and reach `main` as a single cutover (the module system, protocol v3).
|
||||
# With `branches: [main]` alone, every one of those phase PRs merges with NO checks
|
||||
# at all and the entire workstream runs blind until the cutover — which is exactly
|
||||
# what happened to the nine Android M12 phase PRs in that repo. A branch that
|
||||
# accumulates work for weeks needs the gate more than `main` does, not less.
|
||||
#
|
||||
# Enforcement (one-time, in the Gitea UI):
|
||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||
# • Enable Status Check
|
||||
@@ -10,6 +17,10 @@
|
||||
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||
# without needing the dropdown.
|
||||
# The workflow now RUNS on PRs into `edge` too, but running is not enforcing:
|
||||
# blocking a red phase PR needs its own protection rule for `edge`, with the
|
||||
# same `PR Checks / *` pattern. Without one the checks report and merging stays
|
||||
# possible anyway.
|
||||
#
|
||||
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
|
||||
# only Node (no Docker socket), and the server tests stub their models + point the
|
||||
@@ -19,7 +30,7 @@ name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, edge]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
@@ -36,11 +47,43 @@ 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
|
||||
# stack and diffing proves a "mechanical" refactor moved no URL. A PR that
|
||||
# really does change one has to commit the new manifest, putting it in front
|
||||
# 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:
|
||||
@@ -58,9 +101,13 @@ jobs:
|
||||
- name: Build client
|
||||
run: npm run build --prefix client
|
||||
|
||||
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.
|
||||
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.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -71,3 +118,7 @@ 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
|
||||
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
|
||||
server/test/*.test.js
|
||||
|
||||
- name: Generate client test coverage (LCOV)
|
||||
@@ -76,6 +77,7 @@ jobs:
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
|
||||
client/test/*.test.js
|
||||
|
||||
- name: Run SonarQube scan
|
||||
|
||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
||||
name: sync-project-tree
|
||||
|
||||
# Keeps this repo's file-layout snapshot (docs/website/PROJECT_TREE.md in the
|
||||
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
|
||||
# tree from tracked files and, if it changed, opens (or force-updates) a pull
|
||||
# request against the docs repo. It never writes to the docs repo's `main`
|
||||
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
|
||||
# other workflows use (the token needs repo read/write on RunicGateway/docs).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sync-project-tree
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
DOCS_REPO: RunicGateway/docs
|
||||
SELF_REPO: RunicGateway/website
|
||||
DOCS_PATH: website/PROJECT_TREE.md
|
||||
TREE_TITLE: Website
|
||||
ROOT_LABEL: website
|
||||
PR_BRANCH: chore/sync-website-tree
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out this repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Ensure python3 is available
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
|
||||
|
||||
- name: Render PROJECT_TREE.md from tracked files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _sync
|
||||
{
|
||||
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
|
||||
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
|
||||
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
|
||||
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
|
||||
printf '> by hand — changes will be overwritten by the next sync.\n\n'
|
||||
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
|
||||
printf 'git-ignored paths are excluded).\n\n'
|
||||
printf '```text\n'
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
|
||||
printf '```\n'
|
||||
} > _sync/PROJECT_TREE.md
|
||||
echo "----- generated ${DOCS_PATH} -----"
|
||||
cat _sync/PROJECT_TREE.md
|
||||
|
||||
- name: Open or update the docs PR if the tree changed
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Secrets can carry a trailing CR/LF depending on how they were pasted;
|
||||
# strip line breaks before they land in a URL or Authorization header.
|
||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
|
||||
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
|
||||
|
||||
git clone --depth 1 "${REMOTE}" docs_repo
|
||||
cd docs_repo
|
||||
git config user.name "runic-docs-bot"
|
||||
git config user.email "ci@whitlocktech.com"
|
||||
|
||||
mkdir -p "$(dirname "${DOCS_PATH}")"
|
||||
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
|
||||
git add "${DOCS_PATH}"
|
||||
if git diff --cached --quiet; then
|
||||
echo "PROJECT_TREE.md already up to date — nothing to sync."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
|
||||
git checkout -B "${PR_BRANCH}"
|
||||
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
|
||||
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
|
||||
|
||||
# Open a PR only if one isn't already open for this branch (a force-push
|
||||
# to an existing open PR's head updates it in place).
|
||||
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
|
||||
"${API}/pulls?state=open&limit=50" \
|
||||
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
|
||||
if [ "${OPEN}" = "0" ]; then
|
||||
curl -sSf -X POST "${API}/pulls" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg head "${PR_BRANCH}" \
|
||||
--arg base "main" \
|
||||
--arg title "docs(tree): sync ${DOCS_PATH}" \
|
||||
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
|
||||
'{head: $head, base: $base, title: $title, body: $body}')" \
|
||||
>/dev/null
|
||||
echo "Opened a new docs PR for ${PR_BRANCH}."
|
||||
else
|
||||
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
|
||||
fi
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -21,6 +21,31 @@ 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
|
||||
# ignored above; this is the slug -> file-name map pointing at them.
|
||||
# See docs/website/SPAWN_ATLAS.md and db/data/spawnAtlas.art.example.json.
|
||||
server/db/data/spawnAtlas.art.json
|
||||
|
||||
# Operator-supplied cliloc table. UO's localization strings are EA's, extracted
|
||||
# from the operator's own client and converted once (docs/website/CLILOCS.md);
|
||||
# the repo ships no string table, for the same reason it ships no artwork and no
|
||||
# map snapshot. This covers the conventional in-repo location — the supported
|
||||
# arrangement is a path OUTSIDE the repo, set from Admin → Shard.
|
||||
server/db/data/cliloc*
|
||||
server/db/data/clilocs.*
|
||||
# The build output of tools/cliloc-export (a throwaway helper, not a package).
|
||||
server/tools/cliloc-export/bin/
|
||||
server/tools/cliloc-export/obj/
|
||||
|
||||
# reference material (extracted from the provided archives)
|
||||
_reference/
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ If you add or change an API route, regenerate the Swagger spec
|
||||
(`cd server && npm run swagger`) and commit the updated
|
||||
`server/swagger/swagger-output.json`.
|
||||
|
||||
The URL surface is also frozen by a generated manifest. If your change adds,
|
||||
removes or renames a route, regenerate it (`cd server && npm run routes:manifest`)
|
||||
and commit `server/routes.manifest.json` + `server/routes.guards.json` — CI fails
|
||||
otherwise. A non-empty diff in `routes.manifest.json` means you changed the API
|
||||
contract, so call it out in the PR description; a pure refactor must produce none.
|
||||
|
||||
## Branch & PR workflow
|
||||
|
||||
1. Fork or branch from `main`. Use a descriptive branch name
|
||||
|
||||
@@ -21,6 +21,12 @@ 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
|
||||
|
||||
356
README.md
356
README.md
@@ -8,16 +8,19 @@
|
||||
[](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 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.
|
||||
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).
|
||||
|
||||
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.
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
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.
|
||||
|
||||
@@ -25,6 +28,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
|
||||
|
||||
## Contents
|
||||
|
||||
- [Architecture](#architecture)
|
||||
- [Tech stack](#tech-stack)
|
||||
- [Project structure](#project-structure)
|
||||
- [Prerequisites](#prerequisites)
|
||||
@@ -36,7 +40,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)
|
||||
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
||||
- [Modules](#modules)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Security](#security)
|
||||
- [Logging](#logging)
|
||||
@@ -44,6 +48,101 @@ 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.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
%% ---------- Clients ----------
|
||||
subgraph clients["Clients"]
|
||||
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
|
||||
mobile["Native mobile app<br/>(bearer tokens)"]
|
||||
end
|
||||
|
||||
idp["SSO providers<br/>Google · Discord · custom OIDC"]
|
||||
discord["Discord"]
|
||||
|
||||
%% ---------- Website (one repo) ----------
|
||||
subgraph website["website/ — Node app (one repo)"]
|
||||
direction 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"]
|
||||
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"]
|
||||
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>_*")]
|
||||
|
||||
%% ---------- Module side ----------
|
||||
subgraph modside["modules/<id>/ — installed, not built (e.g. Module-uo)"]
|
||||
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"]
|
||||
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
|
||||
browser -.->|"OAuth redirect + PKCE"| idp
|
||||
auth -.->|"token exchange"| idp
|
||||
|
||||
mw --> router --> ctrl
|
||||
ctrl --> auth
|
||||
ctrl --> model
|
||||
ctrl --> sse
|
||||
auth --> model
|
||||
model <--> db
|
||||
auth -. reads/writes secrets .-> secret
|
||||
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
|
||||
|
||||
%% ---------- 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;
|
||||
class db store;
|
||||
class modsrv,modcli mod;
|
||||
```
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Tech |
|
||||
@@ -52,7 +151,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
|
||||
| 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 via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
|
||||
| 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 |
|
||||
| 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, …) |
|
||||
|
||||
@@ -67,12 +166,13 @@ 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 route groups
|
||||
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
|
||||
│ │ ├─ 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)
|
||||
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
|
||||
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
|
||||
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger · htmlShell
|
||||
│ ├─ db/ schema.sql + seed.js
|
||||
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
|
||||
│ ├─ swagger/ swagger.js (generator config) · swagger-output.json (generated, core only) · docsSpec.js (merges module fragments at request time)
|
||||
│ └─ .env.example
|
||||
├─ client/ React + Vite SPA
|
||||
│ ├─ src/
|
||||
@@ -81,9 +181,11 @@ 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)
|
||||
@@ -114,7 +216,12 @@ 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):
|
||||
@@ -125,6 +232,10 @@ 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:
|
||||
@@ -205,7 +316,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 · Shard status |
|
||||
| `/site/about` · `/site/status` | About · Site status |
|
||||
| `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) |
|
||||
|
||||
**Admin** (cookie auth, `noindex`):
|
||||
@@ -223,6 +334,13 @@ 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
|
||||
@@ -234,9 +352,15 @@ npm start # node server → serves API + SPA at http://localhost:3
|
||||
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
||||
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
||||
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
|
||||
| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none |
|
||||
| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
|
||||
| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
|
||||
| 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.
|
||||
|
||||
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||
@@ -279,73 +403,155 @@ 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
|
||||
Express listeners actually expose. It is **not** documentation — it is the machine-checkable freeze of
|
||||
the URL surface, so that carving the router files up by business capability
|
||||
(`docs/website/API_V2_PLAN.md`) can be proved to move no URL instead of merely claiming it.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm run routes:manifest # → routes.manifest.json + routes.guards.json
|
||||
npm run routes:manifest -- --check # exit 1 if either file is stale (what CI runs)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Two generated files, two very different meanings:
|
||||
|
||||
| File | Meaning of a diff |
|
||||
|---|---|
|
||||
| `routes.manifest.json` | **Contract change.** A URL moved. Justify it in the PR description; never let one ride along in a "mechanical" refactor. |
|
||||
| `routes.guards.json` | **Review aid.** Per route: handler count + the *named* middleware on its mount chain. Names are a hint only — `requireRole(...)` returns an anonymous arrow and cannot be seen — but a vanished `requireAuth` is unambiguous. |
|
||||
|
||||
Unlike the Swagger spec, the manifest is annotation-free: `swagger-output.json` documents intent (only
|
||||
annotated routes appear), the manifest records reality.
|
||||
|
||||
---
|
||||
|
||||
## Shard integration (uo-link)
|
||||
## Modules
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
### How it works
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
```
|
||||
ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
|
||||
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
|
||||
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
|
||||
```
|
||||
|
||||
- **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.
|
||||
`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.
|
||||
|
||||
### Account linking
|
||||
### Three ways in, and none of them is a build
|
||||
|
||||
A player (or staff member) proves ownership of a game account without sharing any game credentials:
|
||||
| | 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 |
|
||||
|
||||
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`.
|
||||
`MODULES` takes one entry per module, whitespace- or comma-separated:
|
||||
|
||||
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.
|
||||
```
|
||||
MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
|
||||
```
|
||||
|
||||
### What each audience sees
|
||||
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**.
|
||||
|
||||
| Surface | Endpoints | Who | Data |
|
||||
|---|---|---|---|
|
||||
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. |
|
||||
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
|
||||
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
|
||||
The 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.
|
||||
|
||||
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.
|
||||
### What a module gets, and what it may not do
|
||||
|
||||
At boot, `app.js` scans the volume synchronously, validates each `module.json`, and calls the
|
||||
module's `register(ctx, api)`:
|
||||
|
||||
- **`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.
|
||||
|
||||
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 is running right now
|
||||
|
||||
```
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
@@ -358,6 +564,9 @@ 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) |
|
||||
@@ -375,19 +584,18 @@ 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 (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||
| _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) |
|
||||
| `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 due/retry legs (town crier + Discord) |
|
||||
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## Branding
|
||||
|
||||
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
|
||||
image can run as any shard. With none set, everything renders as **Runic Gateway**.
|
||||
image can run as any community. With none set, everything renders as **Runic Gateway**.
|
||||
|
||||
| Var | What |
|
||||
|---|---|
|
||||
@@ -469,9 +677,11 @@ 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 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.
|
||||
`.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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"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"],
|
||||
|
||||
@@ -1,13 +1,83 @@
|
||||
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
|
||||
// logs carry the instance identity. Kept minimal — the bot only needs the name
|
||||
// and the accent color (as an int for discord.js embeds).
|
||||
//
|
||||
// The accent additionally tracks ADMIN THEMING. An admin who re-themes the site
|
||||
// changes `theme_visual`, which the server resolves into the effective
|
||||
// `brand.accent` on GET /public/settings (docs/website/THEMING_AND_NAV.md
|
||||
// §4.5). This process boots from env and then follows that value, so embeds
|
||||
// don't stay the old color until someone restarts the container.
|
||||
//
|
||||
// Design constraints this satisfies:
|
||||
// • env is always a working answer — a site that is down, unconfigured or
|
||||
// mid-restart never costs the bot its accent, it just keeps the last known
|
||||
// good one;
|
||||
// • reading `brand.accentInt` never awaits and never throws, because it is
|
||||
// read inline while building an embed;
|
||||
// • at most one refresh is ever in flight.
|
||||
require('dotenv').config()
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||
const accentInt = (() => {
|
||||
const n = parseInt(String(accentHex).replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
})()
|
||||
const siteApi = require('./site/siteApiClient')
|
||||
const createLogger = require('./utils/logger')
|
||||
|
||||
module.exports = { name, accentHex, accentInt }
|
||||
const log = createLogger('brand')
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const ENV_ACCENT = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||
|
||||
function toInt(hex) {
|
||||
const n = parseInt(String(hex).replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
}
|
||||
|
||||
// How long a fetched accent is trusted before the next read triggers a refresh.
|
||||
// A theme change reaching Discord within ten minutes is fine; a network call per
|
||||
// embed is not.
|
||||
const TTL_MS = 10 * 60 * 1000
|
||||
|
||||
let accentHex = ENV_ACCENT
|
||||
let accentInt = toInt(ENV_ACCENT)
|
||||
let fetchedAt = 0
|
||||
let inFlight = null
|
||||
|
||||
async function fetchAccent() {
|
||||
const res = await siteApi.getPublicSettings()
|
||||
// Any failure — site down, maintenance, malformed body — leaves the current
|
||||
// value in place. Stamping fetchedAt regardless is deliberate: it stops a
|
||||
// persistently unreachable site from firing a request on every single read.
|
||||
fetchedAt = Date.now()
|
||||
const accent = res.ok ? res.data?.brand?.accent : null
|
||||
if (typeof accent !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) return
|
||||
if (accent === accentHex) return
|
||||
accentHex = accent
|
||||
accentInt = toInt(accent)
|
||||
log.info('embed accent updated from the site', { accent })
|
||||
}
|
||||
|
||||
// Kick off a refresh if the cached value is stale. Never awaited by a reader —
|
||||
// the current value is returned immediately and the next read sees the new one.
|
||||
function refreshIfStale() {
|
||||
if (inFlight || Date.now() - fetchedAt < TTL_MS) return inFlight
|
||||
inFlight = fetchAccent()
|
||||
.catch((err) => log.warn('accent refresh failed — keeping the current value', { message: err.message }))
|
||||
.finally(() => {
|
||||
inFlight = null
|
||||
})
|
||||
return inFlight
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name,
|
||||
// Getters, not values: consumers already read `brand.accentInt` inline when
|
||||
// building an embed, so this keeps the accent current with no call-site change.
|
||||
get accentHex() {
|
||||
refreshIfStale()
|
||||
return accentHex
|
||||
},
|
||||
get accentInt() {
|
||||
refreshIfStale()
|
||||
return accentInt
|
||||
},
|
||||
// Awaited once at startup so the first embed of a process is already correct.
|
||||
refreshAccent: () => refreshIfStale() || Promise.resolve(),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,46 @@ const guildConfig = require('../../model/guildConfig')
|
||||
const inviteLog = require('../../model/inviteLog')
|
||||
const inviteRotator = require('../../invites/inviteRotator')
|
||||
|
||||
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
|
||||
async function handleChannel(interaction) {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
|
||||
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
|
||||
}
|
||||
|
||||
async function handleRotate(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
try {
|
||||
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
|
||||
triggeredBy: interaction.user.id,
|
||||
triggeredByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLog(interaction) {
|
||||
const rows = await inviteLog.list(interaction.guildId, 10)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
const who = r.triggered_by_tag || 'automatic (scheduled)'
|
||||
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
|
||||
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'invite',
|
||||
@@ -40,46 +80,8 @@ module.exports = {
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'channel') {
|
||||
const channel = interaction.options.getChannel('channel')
|
||||
if (!channel) {
|
||||
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
|
||||
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
|
||||
await interaction.reply({ content, ephemeral: true })
|
||||
return
|
||||
}
|
||||
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
|
||||
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'rotate') {
|
||||
await interaction.deferReply({ ephemeral: true })
|
||||
try {
|
||||
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
|
||||
triggeredBy: interaction.user.id,
|
||||
triggeredByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'log') {
|
||||
const rows = await inviteLog.list(interaction.guildId, 10)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
const who = r.triggered_by_tag || 'automatic (scheduled)'
|
||||
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
|
||||
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
if (sub === 'channel') return handleChannel(interaction)
|
||||
if (sub === 'rotate') return handleRotate(interaction)
|
||||
if (sub === 'log') return handleLog(interaction)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,6 +5,72 @@ const scheduledMessages = require('../../model/scheduledMessages')
|
||||
const scheduler = require('../../scheduler/scheduler')
|
||||
const { parseDuration } = require('../../utils/duration')
|
||||
|
||||
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
|
||||
async function handleRecurring(interaction) {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const cronExpr = interaction.options.getString('cron', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
if (!cron.validate(cronExpr)) {
|
||||
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
const id = await scheduledMessages.addRecurring({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
cronExpression: cronExpr,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
|
||||
}
|
||||
|
||||
async function handleOnce(interaction) {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const inInput = interaction.options.getString('in', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
const ms = parseDuration(inInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const runAt = new Date(Date.now() + ms)
|
||||
const id = await scheduledMessages.addOnce({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
runAt,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
|
||||
}
|
||||
|
||||
async function handleRemove(interaction) {
|
||||
const id = interaction.options.getInteger('id', true)
|
||||
const removed = await scheduledMessages.remove(interaction.guildId, id)
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
|
||||
}
|
||||
|
||||
async function handleList(interaction) {
|
||||
const rows = await scheduledMessages.list(interaction.guildId)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
let kind
|
||||
if (r.cron_expression) kind = `cron \`${r.cron_expression}\``
|
||||
else if (r.sent_at) kind = `sent ${new Date(r.sent_at).toLocaleString()}`
|
||||
else kind = `due ${new Date(r.run_at).toLocaleString()}`
|
||||
const suffix = r.enabled ? '' : ' (disabled)'
|
||||
return `**#${r.id}** <#${r.channel_id}> — ${kind}${suffix}`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
data: {
|
||||
name: 'schedule',
|
||||
@@ -47,73 +113,9 @@ module.exports = {
|
||||
},
|
||||
async execute(interaction) {
|
||||
const sub = interaction.options.getSubcommand()
|
||||
|
||||
if (sub === 'recurring') {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const cronExpr = interaction.options.getString('cron', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
if (!cron.validate(cronExpr)) {
|
||||
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
const id = await scheduledMessages.addRecurring({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
cronExpression: cronExpr,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'once') {
|
||||
const channel = interaction.options.getChannel('channel', true)
|
||||
const inInput = interaction.options.getString('in', true)
|
||||
const message = interaction.options.getString('message', true)
|
||||
const ms = parseDuration(inInput)
|
||||
if (!ms) {
|
||||
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const runAt = new Date(Date.now() + ms)
|
||||
const id = await scheduledMessages.addOnce({
|
||||
guildId: interaction.guildId,
|
||||
channelId: channel.id,
|
||||
content: message,
|
||||
runAt,
|
||||
createdBy: interaction.user.id,
|
||||
createdByTag: interaction.user.tag,
|
||||
})
|
||||
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const id = interaction.options.getInteger('id', true)
|
||||
const removed = await scheduledMessages.remove(interaction.guildId, id)
|
||||
await scheduler.refresh()
|
||||
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
const rows = await scheduledMessages.list(interaction.guildId)
|
||||
if (rows.length === 0) {
|
||||
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
|
||||
return
|
||||
}
|
||||
const lines = rows.map((r) => {
|
||||
const kind = r.cron_expression
|
||||
? `cron \`${r.cron_expression}\``
|
||||
: r.sent_at
|
||||
? `sent ${new Date(r.sent_at).toLocaleString()}`
|
||||
: `due ${new Date(r.run_at).toLocaleString()}`
|
||||
return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}`
|
||||
})
|
||||
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||
}
|
||||
if (sub === 'recurring') return handleRecurring(interaction)
|
||||
if (sub === 'once') return handleOnce(interaction)
|
||||
if (sub === 'remove') return handleRemove(interaction)
|
||||
if (sub === 'list') return handleList(interaction)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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')
|
||||
@@ -22,12 +23,46 @@ 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),
|
||||
body: [...commands.all.map((c) => c.data), ...dynamic],
|
||||
})
|
||||
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
|
||||
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
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
@@ -50,6 +85,52 @@ async function stop() {
|
||||
log.info('discord client disconnected')
|
||||
}
|
||||
|
||||
// Post-login startup: register commands and start the background workers. A
|
||||
// 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)
|
||||
inviteScheduler.start(client, guildId)
|
||||
await inviteTracker.prime(client, guildId)
|
||||
status = 'connected'
|
||||
statusDetail = null
|
||||
lastConnectedAt = new Date()
|
||||
log.info('discord client ready', { user: client.user?.tag, guildId })
|
||||
} catch (err) {
|
||||
status = 'error'
|
||||
statusDetail = `startup failed: ${err.message}`
|
||||
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
try {
|
||||
if (command) await command.execute(interaction)
|
||||
else await dynamicCommands.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 }
|
||||
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
|
||||
else await interaction.reply(payload)
|
||||
}
|
||||
}
|
||||
|
||||
// start({ token, guildId }) — (re)connects. Always stops any existing client
|
||||
// first so re-saving config or toggling Enabled off/on is idempotent.
|
||||
async function start({ token, guildId: gid }) {
|
||||
@@ -72,39 +153,8 @@ async function start({ token, guildId: gid }) {
|
||||
],
|
||||
})
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
await registerCommands(client.application.id, guildId)
|
||||
await scheduler.start(client)
|
||||
tempRoleSweeper.start(client)
|
||||
inviteScheduler.start(client, guildId)
|
||||
await inviteTracker.prime(client, guildId)
|
||||
status = 'connected'
|
||||
statusDetail = null
|
||||
lastConnectedAt = new Date()
|
||||
log.info('discord client ready', { user: client.user?.tag, guildId })
|
||||
} catch (err) {
|
||||
status = 'error'
|
||||
statusDetail = `startup failed: ${err.message}`
|
||||
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
client.on('interactionCreate', async (interaction) => {
|
||||
if (await roleMenuHandler.handleInteraction(interaction)) return
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
const command = commands.get(interaction.commandName)
|
||||
if (!command) return
|
||||
try {
|
||||
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 }
|
||||
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
|
||||
else await interaction.reply(payload)
|
||||
}
|
||||
})
|
||||
|
||||
client.once('ready', onReady)
|
||||
client.on('interactionCreate', onInteractionCreate)
|
||||
client.on('messageCreate', messageFilter.handleMessageCreate)
|
||||
client.on('guildMemberAdd', handleGuildMemberAdd)
|
||||
client.on('guildMemberRemove', handleGuildMemberRemove)
|
||||
@@ -141,4 +191,4 @@ function getConnection() {
|
||||
return { client, guildId }
|
||||
}
|
||||
|
||||
module.exports = { start, stop, getStatus, getConnection }
|
||||
module.exports = { start, stop, getStatus, getConnection, refreshCommands }
|
||||
|
||||
242
bot/src/discord/dynamicCommands.js
Normal file
242
bot/src/discord/dynamicCommands.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// 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 }
|
||||
@@ -66,8 +66,7 @@ function detectSpam(message) {
|
||||
async function isBypassed(message, cache) {
|
||||
if (cache.allowChannels.has(message.channelId)) return true
|
||||
const memberRoles = message.member ? message.member.roles.cache : null
|
||||
if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true
|
||||
return false
|
||||
return Boolean(memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id)))
|
||||
}
|
||||
|
||||
async function applyWarnAction(message, reason) {
|
||||
|
||||
80
bot/src/discord/teamNotify.js
Normal file
80
bot/src/discord/teamNotify.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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 }
|
||||
315
bot/src/discord/teamVoice.js
Normal file
315
bot/src/discord/teamVoice.js
Normal file
@@ -0,0 +1,315 @@
|
||||
// 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,
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// current guild (anti-raid/anti-advertising). An invite that fails to resolve
|
||||
// (expired/invalid/vanity-only) is treated as foreign too — safer default
|
||||
// than silently letting an unresolvable link through.
|
||||
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
|
||||
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-z0-9-]+)/gi
|
||||
|
||||
// Returns the first foreign (or unresolvable) invite code found in the message,
|
||||
// or null if the message contains no foreign invites. Returning the code (rather
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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')
|
||||
|
||||
@@ -99,4 +101,142 @@ async function reverseModAction(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }
|
||||
// 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,
|
||||
}
|
||||
|
||||
@@ -11,5 +11,10 @@ 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
|
||||
|
||||
@@ -21,6 +21,11 @@ async function start() {
|
||||
log.info(`internal API listening on http://${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
// Pick up the site's effective accent before the first embed can be built.
|
||||
// Best-effort by design: it never rejects, and a site that is not up yet just
|
||||
// leaves the bot on its BRAND_ACCENT_COLOR default until the next read.
|
||||
await brand.refreshAccent()
|
||||
|
||||
await bootstrap()
|
||||
|
||||
setupShutdown(server)
|
||||
|
||||
76
bot/src/site/appInternalClient.js
Normal file
76
bot/src/site/appInternalClient.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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 }
|
||||
@@ -31,6 +31,15 @@ async function call(path) {
|
||||
}
|
||||
}
|
||||
|
||||
// The site's public settings, including the brand block. Used for the embed
|
||||
// accent (see brand.js): the admin can theme the site at runtime, and the
|
||||
// server resolves the effective accent into brand.accent, so this is how the
|
||||
// bot's embeds track a theme change instead of being stuck on the value
|
||||
// BRAND_ACCENT_COLOR had when the container started.
|
||||
function getPublicSettings() {
|
||||
return call('/settings')
|
||||
}
|
||||
|
||||
function getNewsPost(idOrSlug) {
|
||||
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||
}
|
||||
@@ -39,4 +48,4 @@ function searchWiki(query) {
|
||||
return call(`/wiki?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
module.exports = { getNewsPost, searchWiki }
|
||||
module.exports = { getPublicSettings, getNewsPost, searchWiki }
|
||||
|
||||
@@ -7,7 +7,7 @@ const MAX_TIMEOUT_MS = 28 * 86_400_000
|
||||
|
||||
function parseDuration(input) {
|
||||
if (!input) return null
|
||||
const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim())
|
||||
const match = /^(\d+)\s*([smhd])$/i.exec(input.trim())
|
||||
if (!match) return null
|
||||
const [, amount, unit] = match
|
||||
return Number(amount) * UNIT_MS[unit.toLowerCase()]
|
||||
|
||||
77
bot/test/appInternalClient.test.js
Normal file
77
bot/test/appInternalClient.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// 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)
|
||||
})
|
||||
269
bot/test/dynamicCommands.test.js
Normal file
269
bot/test/dynamicCommands.test.js
Normal file
@@ -0,0 +1,269 @@
|
||||
// ── 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')
|
||||
})
|
||||
138
bot/test/teamNotify.test.js
Normal file
138
bot/test/teamNotify.test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// ── 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')
|
||||
})
|
||||
364
bot/test/teamVoice.test.js
Normal file
364
bot/test/teamVoice.test.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// ── 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)
|
||||
})
|
||||
@@ -7,7 +7,16 @@
|
||||
<meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- The eight web families behind the admin font shortlist
|
||||
(docs/website/THEMING_AND_NAV.md §5), in one combined css2? request.
|
||||
Static and never built from admin input: the dropdown stores a full
|
||||
font-family stack from a closed set, and only the families actually
|
||||
applied have their binaries fetched. Both hosts are already in the CSP
|
||||
(server/src/config/csp.js), so this needs no policy change. -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
62
client/package-lock.json
generated
62
client/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "runic-gateway-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
@@ -306,6 +309,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -2488,6 +2544,12 @@
|
||||
"@popperjs/core": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
|
||||
@@ -5,6 +5,8 @@ import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||
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'
|
||||
@@ -16,12 +18,6 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||
import About from './routes/public/About.jsx'
|
||||
import Status from './routes/public/Status.jsx'
|
||||
import Shard from './routes/public/Shard.jsx'
|
||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||
import 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 Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
@@ -35,160 +31,261 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
|
||||
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
||||
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||
import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx'
|
||||
import NavEditor from './routes/admin/views/NavEditor.jsx'
|
||||
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 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 HousesAdmin from './routes/admin/views/HousesAdmin.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 TeamsAdmin from './routes/admin/views/TeamsAdmin.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 from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.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'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SiteProvider>
|
||||
<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 />} />
|
||||
{/* 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 />} />
|
||||
|
||||
{/* 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="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* 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 />} />
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
||||
<Route
|
||||
path="moderation"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<MaintenanceGate>
|
||||
<Outlet />
|
||||
</RoleGate>
|
||||
</MaintenanceGate>
|
||||
}
|
||||
>
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
<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="/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>
|
||||
<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-ops"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<ShardOps />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="houses"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<HousesAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<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 />} />
|
||||
<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 />} />
|
||||
<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>
|
||||
{/* 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 />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
{/* 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={
|
||||
<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 />} />
|
||||
{/* 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>
|
||||
<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 />} />
|
||||
{/* 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
|
||||
element={
|
||||
<RequirePlayer>
|
||||
<PlayerPortalLayout />
|
||||
</RequirePlayer>
|
||||
}
|
||||
>
|
||||
{/* 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 />} />
|
||||
{/* 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="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</ModuleFeaturesProvider>
|
||||
</SiteProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// same-origin API (/api/v1) — proxied to the Express server in dev.
|
||||
const BASE = '/api/v1'
|
||||
|
||||
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
|
||||
// callers can append it to a path without a dangling "?".
|
||||
const withQs = (s) => (s ? `?${s}` : '')
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, message, body) {
|
||||
super(message)
|
||||
@@ -38,6 +42,20 @@ 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 `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 }
|
||||
|
||||
export const api = {
|
||||
// ----- auth -----
|
||||
me: () => req('/auth/me'),
|
||||
@@ -52,8 +70,12 @@ export const api = {
|
||||
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
||||
acceptInvite: (token, username, password, extra = {}) =>
|
||||
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||
loginTotp: (challenge, code) =>
|
||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||
// Second factor for web login. `extra` carries the optional recoveryCode (an
|
||||
// alternative to code) and the trustDevice/deviceName opt-in. On success the
|
||||
// response may include { trustLimitReached, devices } when trust was requested
|
||||
// but the device cap is reached.
|
||||
loginTotp: (challenge, code, extra = {}) =>
|
||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
|
||||
// Self-service password reset (public, token-gated). forgot always resolves the
|
||||
// same way whether or not the email exists (no enumeration); getPasswordReset
|
||||
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword
|
||||
@@ -63,8 +85,10 @@ export const api = {
|
||||
resetPassword: (token, password) =>
|
||||
req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
|
||||
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
||||
// the callback, so only the code is sent). Returns { user, returnTo }.
|
||||
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
||||
// the callback, so only the code is sent). `extra` carries the trustDevice/
|
||||
// deviceName opt-in, same as the password path. Returns { user, returnTo } — plus
|
||||
// { trustLimitReached, devices } when trust was asked for but the cap is reached.
|
||||
ssoLoginTotp: (code, extra = {}) => req('/auth/sso/totp', { method: 'POST', body: { code, ...extra } }),
|
||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||
authProviders: () => req('/auth/providers'),
|
||||
@@ -72,6 +96,58 @@ export const api = {
|
||||
// List the active ones and revoke a single device by its session id.
|
||||
mySessions: () => req('/auth/me/sessions'),
|
||||
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
// Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
|
||||
// are the browsers/apps allowed to skip the TOTP step at login (distinct from
|
||||
// mySessions, which are live mobile login sessions).
|
||||
myTrustedDevices: () => req('/auth/me/trusted-devices'),
|
||||
trustThisDevice: (deviceName) =>
|
||||
req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
|
||||
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'),
|
||||
generateRecoveryCodes: (currentPassword) =>
|
||||
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
|
||||
|
||||
// ----- settings (any authenticated account) -----
|
||||
// Nav overrides for the layouts the caller's own role renders, and the theme
|
||||
// catalog the appearance form is built from. A fifth group, not part of
|
||||
// /admin, because AdminLayout renders for editors and moderators too — see
|
||||
// docs/website/THEMING_AND_NAV.md §4.2.
|
||||
navSettings: () => req('/settings/nav'),
|
||||
themeOptions: () => req('/settings/theme/options'),
|
||||
|
||||
// ----- public -----
|
||||
publicSettings: () => req('/public/settings'),
|
||||
@@ -84,9 +160,102 @@ export const api = {
|
||||
if (opts.tag) qs.set('tag', opts.tag)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
const s = qs.toString()
|
||||
return req(`/public/wiki${s ? `?${s}` : ''}`)
|
||||
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' }),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
@@ -95,41 +264,14 @@ export const api = {
|
||||
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
||||
|
||||
// ----- shard live data (uo-link) -----
|
||||
// Token-free, same-origin reads backed by the ingested feed + a cached live
|
||||
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
|
||||
shard: {
|
||||
status: () => req('/public/shard/status'),
|
||||
feed: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.kind) qs.set('kind', opts.kind)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
const s = qs.toString()
|
||||
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
|
||||
},
|
||||
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
||||
online: () => req('/public/shard/online'),
|
||||
idoc: () => req('/public/shard/idoc'),
|
||||
champs: () => req('/public/shard/champs'),
|
||||
// Protocol 2.0 boards.
|
||||
guilds: () => req('/public/shard/guilds'),
|
||||
governors: () => req('/public/shard/governors'),
|
||||
governorHistory: (city, limit) =>
|
||||
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`),
|
||||
presence: () => req('/public/shard/presence'),
|
||||
houses: () => req('/public/shard/houses'),
|
||||
},
|
||||
// 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'),
|
||||
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
|
||||
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`),
|
||||
listPosts: (category) => {
|
||||
const q = category ? `category=${category}` : ''
|
||||
return req(`/admin/posts${withQs(q)}`)
|
||||
},
|
||||
getPost: (id) => req(`/admin/posts/${id}`),
|
||||
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
|
||||
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
|
||||
@@ -179,6 +321,20 @@ export const api = {
|
||||
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
||||
getSettings: () => req('/admin/settings'),
|
||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||
// Reset one setting to its default by deleting the row — the theming/nav
|
||||
// keys and the hero draft only (the server holds the allowlist). Idempotent,
|
||||
// so the caller need not know whether a row exists.
|
||||
resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }),
|
||||
// Upload one brand asset (logo | hero | favicon) and set it as the override
|
||||
// in the same call → { url, brand_assets }. A separate endpoint from the
|
||||
// generic upload above because the server applies per-slot rules (favicons
|
||||
// are PNG-only and capped small) and writes the settings row itself, so an
|
||||
// upload never leaves a file nothing points at.
|
||||
uploadBrandAsset: (slot, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return req(`/admin/settings/brand-asset/${encodeURIComponent(slot)}`, { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||
botActivity: () => req('/admin/bot-activity'),
|
||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||
@@ -187,36 +343,178 @@ 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) =>
|
||||
req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
|
||||
revokeAllUserTrustedDevices: (id) =>
|
||||
req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
|
||||
resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
|
||||
// Email invites.
|
||||
listInvites: () => req('/admin/invites'),
|
||||
createInvite: (email, role, sendEmail = true) =>
|
||||
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
||||
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
||||
// 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' }),
|
||||
}),
|
||||
|
||||
// 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. The list
|
||||
// never returns a hash to use instead.
|
||||
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 } }),
|
||||
|
||||
// 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 } }),
|
||||
|
||||
// ----- 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)
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
|
||||
return req(`/admin/moderation/recent${withQs(s)}`)
|
||||
},
|
||||
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
||||
modMembers: (params = {}) => {
|
||||
@@ -225,21 +523,21 @@ export const api = {
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
|
||||
return req(`/admin/moderation/members${withQs(s)}`)
|
||||
},
|
||||
modFilterHits: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
|
||||
return req(`/admin/moderation/filter-hits${withQs(s)}`)
|
||||
},
|
||||
modSpamHits: (params = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
|
||||
return req(`/admin/moderation/spam-hits${withQs(s)}`)
|
||||
},
|
||||
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
||||
modUserActions: (discordId, params = {}) => {
|
||||
@@ -248,7 +546,7 @@ export const api = {
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
|
||||
return req(`/admin/moderation/user/${discordId}/actions${withQs(s)}`)
|
||||
},
|
||||
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
||||
addModNote: (discordId, data) =>
|
||||
@@ -261,7 +559,7 @@ export const api = {
|
||||
if (params.limit) qs.set('limit', params.limit)
|
||||
if (params.offset) qs.set('offset', params.offset)
|
||||
const s = qs.toString()
|
||||
return req(`/admin/moderation/appeals${s ? `?${s}` : ''}`)
|
||||
return req(`/admin/moderation/appeals${withQs(s)}`)
|
||||
},
|
||||
getAppeal: (id) => req(`/admin/moderation/appeals/${id}`),
|
||||
claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }),
|
||||
@@ -269,29 +567,6 @@ 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 }),
|
||||
@@ -302,62 +577,19 @@ export const api = {
|
||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||
|
||||
// ----- uo-link sidecar control (admin only) -----
|
||||
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
||||
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- 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) -----
|
||||
// ----- 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.
|
||||
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') -----
|
||||
// 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.
|
||||
// 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.
|
||||
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'),
|
||||
|
||||
33
client/src/components/BrandLogo.jsx
Normal file
33
client/src/components/BrandLogo.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
|
||||
// The instance logo, shown beside the MoonDot wherever the site says its own
|
||||
// name (docs/website/THEMING_AND_NAV.md phase 5).
|
||||
//
|
||||
// Renders NOTHING unless this instance has a logo — `brand.logo` is the uploaded
|
||||
// override or BRAND_LOGO, and its default is the empty string. That is what
|
||||
// keeps an untouched instance byte-for-byte as today: the MoonDot stands alone
|
||||
// exactly as it does now, and the logo is an addition an operator opts into.
|
||||
//
|
||||
// It sits beside the moon rather than replacing it. The moon is the app's own
|
||||
// mark and appears on surfaces (maintenance, login) that must render before the
|
||||
// settings fetch resolves; swapping it out would leave those momentarily blank.
|
||||
//
|
||||
// Deliberately not used for the footer's "powered by Runic Gateway" emblem
|
||||
// (SiteFooter.jsx) — that badge is the project's mark, not the instance's, and
|
||||
// must not follow brand_assets (§4.11).
|
||||
export default function BrandLogo({ height = 22, alt = '', style }) {
|
||||
const { brand, siteTitle } = useSite()
|
||||
if (!brand.logo) return null
|
||||
return (
|
||||
<img
|
||||
src={brand.logo}
|
||||
// Decorative by default: every call site puts the site title in text right
|
||||
// next to it, so alt text here would have a screen reader say the name
|
||||
// twice. A caller that renders the logo alone passes its own alt.
|
||||
alt={alt || ''}
|
||||
aria-hidden={alt ? undefined : true}
|
||||
title={siteTitle}
|
||||
style={{ height, width: 'auto', maxWidth: height * 6, objectFit: 'contain', display: 'block', ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
// 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' }
|
||||
|
||||
// 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. Without a cliloc table on the site we can only show literals, so
|
||||
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
||||
// de-duped list of human-readable title chips.
|
||||
function displayTitles(titles) {
|
||||
if (!titles) return []
|
||||
const out = []
|
||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||
if (titles.skill) out.push(titles.skill)
|
||||
const reward = Array.isArray(titles.reward) ? titles.reward : []
|
||||
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||
// Prefer the selected reward title; fall back to the first literal one.
|
||||
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
||||
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
||||
return [...new Set(out.filter(Boolean))]
|
||||
}
|
||||
|
||||
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 || []
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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) => (
|
||||
<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' }}>{it.layer || 'Item'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</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>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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)))
|
||||
let chars = 0
|
||||
let online = 0
|
||||
let complete = true
|
||||
for (const r of rosters) {
|
||||
if (r.status === 'fulfilled') {
|
||||
const cs = r.value.chars || []
|
||||
chars += cs.length
|
||||
online += cs.filter((c) => c.online).length
|
||||
} else {
|
||||
complete = false
|
||||
}
|
||||
}
|
||||
if (!cancelled) setStats({ linked, chars, online, complete })
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
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) {
|
||||
setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (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>
|
||||
)
|
||||
}
|
||||
@@ -34,14 +34,15 @@ function TextBlock({ props }) {
|
||||
const align = props.align || 'center'
|
||||
return (
|
||||
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||
{(props.lines || []).map((line, i) => {
|
||||
{(props.lines || []).map((line) => {
|
||||
const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p'
|
||||
const key = `${line.tag}:${(line.text || '').slice(0, 40)}`
|
||||
// A rich-text line (e.g. the homepage teaser) carries sanitized HTML;
|
||||
// sanitize again on render as defense in depth. Others render as text.
|
||||
if (line.html) {
|
||||
return (
|
||||
<Tag
|
||||
key={i}
|
||||
key={key}
|
||||
className="hero-rich"
|
||||
style={lineStyle(line)}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(line.text || '') }}
|
||||
@@ -49,7 +50,7 @@ function TextBlock({ props }) {
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag key={i} style={lineStyle(line)}>
|
||||
<Tag key={key} style={lineStyle(line)}>
|
||||
{line.text}
|
||||
</Tag>
|
||||
)
|
||||
@@ -59,11 +60,11 @@ function TextBlock({ props }) {
|
||||
}
|
||||
|
||||
function Buttons({ props }) {
|
||||
const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center'
|
||||
const justify = { left: 'flex-start', right: 'flex-end' }[props.align] || 'center'
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}>
|
||||
{(props.items || []).map((b, i) => (
|
||||
<Link key={i} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}>
|
||||
{(props.items || []).map((b) => (
|
||||
<Link key={`${b.to || ''}:${b.label || ''}`} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}>
|
||||
{b.label}
|
||||
</Link>
|
||||
))}
|
||||
@@ -160,12 +161,7 @@ export default function HeroElement({
|
||||
children,
|
||||
}) {
|
||||
const anchor = element.anchor || 'center'
|
||||
const transform =
|
||||
anchor === 'center'
|
||||
? 'translate(-50%, -50%)'
|
||||
: anchor === 'top-right'
|
||||
? 'translateX(-100%)'
|
||||
: undefined
|
||||
const transform = { center: 'translate(-50%, -50%)', 'top-right': 'translateX(-100%)' }[anchor]
|
||||
// text_block/buttons may set a box width (px); kept within the containing block
|
||||
// (the hero section live, or the editor canvas) with small side gutters.
|
||||
const boxWidth =
|
||||
|
||||
@@ -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 shard is in maintenance, visitors see the
|
||||
// Wraps the public site. When the site 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()
|
||||
|
||||
150
client/src/components/NavDropdown.jsx
Normal file
150
client/src/components/NavDropdown.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
|
||||
// One dropdown section in the public header — a menu an admin created from
|
||||
// Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
|
||||
//
|
||||
// It **opens on click, never on hover**. Hover menus are unusable on touch, and
|
||||
// the alternative (make the trigger a link too) means tapping to open navigates
|
||||
// away instead. A section is a container, not a destination, so the trigger has
|
||||
// no `to` at all.
|
||||
//
|
||||
// Everything else here is the keyboard and dismissal contract a menu needs:
|
||||
// Escape closes and returns focus to the trigger, an outside press closes,
|
||||
// navigating closes, and Arrow Up/Down walk the items. `aria-haspopup` +
|
||||
// `aria-expanded` are what let a screen reader announce it as a menu rather than
|
||||
// as a button that mysteriously changes the page.
|
||||
export default function NavDropdown({ label, items, linkStyle }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef(null)
|
||||
const triggerRef = useRef(null)
|
||||
const location = useLocation()
|
||||
|
||||
// The trigger shows the active treatment when the page you are on lives in
|
||||
// this menu — otherwise entering a section makes the header look like nothing
|
||||
// is selected.
|
||||
const holdsActive = items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
||||
|
||||
// Close on navigation. The menu is rendered inside a sticky header that
|
||||
// survives route changes, so nothing else would dismiss it.
|
||||
useEffect(() => setOpen(false), [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape') return
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
// `mousedown`, not `click`: closing on the press means a press that lands on
|
||||
// another trigger opens that one in the same gesture.
|
||||
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])
|
||||
|
||||
// Roving focus with the arrow keys, wrapping at both ends.
|
||||
const onMenuKeyDown = (e) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
|
||||
e.preventDefault()
|
||||
const links = [...(wrapRef.current?.querySelectorAll('[data-menu-item]') || [])]
|
||||
if (links.length === 0) return
|
||||
const at = links.indexOf(document.activeElement)
|
||||
const next = e.key === 'ArrowDown' ? (at + 1) % links.length : (at - 1 + links.length) % links.length
|
||||
links[at === -1 ? 0 : next].focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative' }} onKeyDown={onMenuKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="pill"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
...(holdsActive || open
|
||||
? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
left: 0,
|
||||
minWidth: 190,
|
||||
// The header wraps, so a menu near the right edge must not push the
|
||||
// page sideways on a narrow screen.
|
||||
maxWidth: 'calc(100vw - 24px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
padding: 6,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--panel-flat)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.kind === 'link' ? item.id : item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
role="menuitem"
|
||||
data-menu-item=""
|
||||
onClick={() => setOpen(false)}
|
||||
className="sans"
|
||||
style={({ isActive }) => ({
|
||||
padding: '7px 10px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
fontSize: '0.85rem',
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
...linkStyle({ isActive }),
|
||||
...(isActive ? {} : { color: 'var(--muted)' }),
|
||||
})}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
353
client/src/components/NotificationBell.jsx
Normal file
353
client/src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,353 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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,12 +1,36 @@
|
||||
import SiteHeader from './SiteHeader.jsx'
|
||||
import SiteFooter from './SiteFooter.jsx'
|
||||
import { shellClass } from '../lib/pageShell.js'
|
||||
|
||||
// Standard page chrome for the public site + wiki.
|
||||
export default function PublicLayout({ section = 'website', header = true, children }) {
|
||||
//
|
||||
// ── `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)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{header && <SiteHeader section={section} />}
|
||||
{children}
|
||||
{bodyClass ? <div className={bodyClass}>{children}</div> : children}
|
||||
<SiteFooter />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ function AlignIcon({ align }) {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
|
||||
{rows.map(([x1, x2], i) => (
|
||||
<line key={i} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} />
|
||||
<line key={`${x1}-${x2}`} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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) =>
|
||||
`Kicked${r && r.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`,
|
||||
)
|
||||
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)
|
||||
return `Banned${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`
|
||||
})
|
||||
|
||||
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,5 +1,14 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import Slot from '../modules/Slot.jsx'
|
||||
|
||||
const FOOTER_SLOT = 'site.footer.status'
|
||||
|
||||
// Handed to the extension rather than left for it to guess. A module rendering
|
||||
// its own link in this row should look like the row, and the alternative is
|
||||
// every module restating core's colours and then drifting from them the next
|
||||
// time this footer is themed.
|
||||
const LINK_STYLE = { color: 'var(--accent)', textDecoration: 'none' }
|
||||
|
||||
export default function SiteFooter() {
|
||||
const { contactEmail, siteTitle } = useSite()
|
||||
@@ -31,15 +40,19 @@ export default function SiteFooter() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="site-footer-info">
|
||||
<span>{siteTitle} is an independent private shard project.</span>
|
||||
<span>{siteTitle} is an independent, privately-run game server.</span>
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
{contactEmail}
|
||||
</a>
|
||||
·
|
||||
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Shard Status
|
||||
</Link>
|
||||
{/* 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="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
|
||||
Admin
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
import BrandLogo from './BrandLogo.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import NavDropdown from './NavDropdown.jsx'
|
||||
import NotificationBell from './NotificationBell.jsx'
|
||||
import { buildPublicNav, pruneNav } from '../lib/navOverrides.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).
|
||||
const NAV = [
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// 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.
|
||||
export const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
{ label: 'Shard', to: '/site/shard' },
|
||||
{ label: 'Champions', to: '/site/champs' },
|
||||
{ label: 'Guilds', to: '/site/guilds' },
|
||||
{ label: 'Governors', to: '/site/governors' },
|
||||
{ label: 'Houses', to: '/site/houses' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
@@ -28,15 +44,35 @@ const linkStyle = ({ isActive }) => ({
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { user, loading } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
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'), [])
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
const nav = useMemo(() => {
|
||||
const tree = buildPublicNav(baseNav, parseJsonSetting(settings.nav_public))
|
||||
return pruneNav(tree, isVisible)
|
||||
}, [baseNav, settings.nav_public, isVisible])
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
const account =
|
||||
user && user.role && user.role !== 'player'
|
||||
? { label: 'Admin', to: '/admin' }
|
||||
: user
|
||||
? { label: 'My Account', to: '/player' }
|
||||
: { label: 'Sign in', to: '/account/login' }
|
||||
let account
|
||||
if (user && user.role && user.role !== 'player') account = { label: 'Admin', to: '/admin' }
|
||||
else if (user) account = { label: 'My Account', to: '/player' }
|
||||
else account = { label: 'Sign in', to: '/account/login' }
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -58,15 +94,24 @@ export default function SiteHeader() {
|
||||
className="display"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
||||
>
|
||||
<BrandLogo height={22} />
|
||||
<MoonDot />
|
||||
{siteTitle}
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{NAV.map((l) => (
|
||||
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{nav.map((l) =>
|
||||
l.kind === 'section' ? (
|
||||
<NavDropdown key={l.id} label={l.label} items={l.items} linkStyle={linkStyle} />
|
||||
) : (
|
||||
<NavLink key={l.kind === 'link' ? l.id : l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</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}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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, i) => (
|
||||
<li key={`${s.t}-${i}`} 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>
|
||||
)
|
||||
}
|
||||
175
client/src/components/security/EmailAddressPanel.jsx
Normal file
175
client/src/components/security/EmailAddressPanel.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
62
client/src/components/security/RecoveryCodesDisplay.jsx
Normal file
62
client/src/components/security/RecoveryCodesDisplay.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// Renders a freshly generated batch of recovery codes ONCE, with copy + download.
|
||||
// The backend never returns these again, so the copy stresses saving them now.
|
||||
export default function RecoveryCodesDisplay({ codes, onDone }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const text = (codes || []).join('\n')
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
/* clipboard blocked — the codes are visible to copy manually */
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
const blob = new Blob([`${text}\n`], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'recovery-codes.txt'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 18, marginTop: 8 }}>
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Save these recovery codes somewhere safe. Each can be used <strong>once</strong> to sign in if you
|
||||
lose your authenticator. <strong>They will not be shown again.</strong>
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
|
||||
gap: 8,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.95rem',
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
{(codes || []).map((c) => (
|
||||
<div key={c} style={{ padding: '8px 10px', border: '1px solid var(--line-soft)', borderRadius: 6, letterSpacing: '0.06em', textAlign: 'center', color: 'var(--head)' }}>
|
||||
{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={copy} className="pill">{copied ? 'Copied!' : 'Copy'}</button>
|
||||
<button onClick={download} className="pill">Download</button>
|
||||
{onDone && (
|
||||
<button onClick={onDone} className="btn btn-primary btn-sq" style={{ marginLeft: 'auto' }}>
|
||||
I’ve saved them
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
client/src/components/security/RecoveryCodesPanel.jsx
Normal file
86
client/src/components/security/RecoveryCodesPanel.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
import RecoveryCodesDisplay from './RecoveryCodesDisplay.jsx'
|
||||
|
||||
// Self-service recovery (backup) codes. Shows how many remain and lets the user
|
||||
// regenerate a fresh set (password step-up). Shown only when 2FA is enabled.
|
||||
// `hasPassword` decides whether the current-password field is required — an
|
||||
// SSO-only account with no password may regenerate while authenticated.
|
||||
export default function RecoveryCodesPanel({ hasPassword = true }) {
|
||||
const [remaining, setRemaining] = useState(null)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [codes, setCodes] = useState(null) // freshly generated batch, shown once
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { remaining: n } = await api.recoveryCodesStatus()
|
||||
setRemaining(n)
|
||||
} catch {
|
||||
/* non-fatal — the panel still offers regeneration */
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function regenerate() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const { recoveryCodes } = await api.generateRecoveryCodes(hasPassword ? currentPassword : undefined)
|
||||
setCodes(recoveryCodes)
|
||||
setCurrentPassword('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not generate recovery codes.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Recovery codes
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Single-use codes that let you sign in if you lose your authenticator. Regenerating replaces any
|
||||
codes you still have.
|
||||
</p>
|
||||
|
||||
{remaining != null && !codes && (
|
||||
<p className="sans" style={{ color: remaining > 0 ? '#7fd0a4' : '#e0b352', fontSize: '0.86rem' }}>
|
||||
{remaining > 0 ? `${remaining} unused code${remaining === 1 ? '' : 's'} remaining.` : 'No unused recovery codes left — regenerate a set.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{codes ? (
|
||||
<RecoveryCodesDisplay codes={codes} onDone={() => setCodes(null)} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 10 }}>
|
||||
{hasPassword && (
|
||||
<label style={{ display: 'block', maxWidth: 260 }}>
|
||||
<span className="field-label">Current password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div>
|
||||
<button onClick={regenerate} disabled={busy || (hasPassword && !currentPassword)} className="btn btn-sq">
|
||||
{busy ? 'Generating…' : 'Generate new codes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
client/src/components/security/TrustLimitModal.jsx
Normal file
124
client/src/components/security/TrustLimitModal.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Shown when a user tries to trust a device but is already at the trusted-device
|
||||
// cap. Styled like the TOTP entry flow (centered card on a dim overlay). The user
|
||||
// MUST revoke at least one existing device before they can continue — there is no
|
||||
// silent pruning — or they can cancel and leave the device untrusted.
|
||||
//
|
||||
// Props:
|
||||
// devices — the existing trusted devices (from the 409 / trustLimitReached payload)
|
||||
// onTrusted — called after the current device is successfully trusted (post-revoke)
|
||||
// onCancel — called when the user backs out without trusting this device
|
||||
export default function TrustLimitModal({ devices: initialDevices, onTrusted, onCancel }) {
|
||||
const [devices, setDevices] = useState(initialDevices || [])
|
||||
const [revokedAny, setRevokedAny] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function revoke(id) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.revokeTrustedDevice(id)
|
||||
setDevices((list) => list.filter((d) => d.id !== id))
|
||||
setRevokedAny(true)
|
||||
} catch {
|
||||
setError('Could not revoke that device. Please try again.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function trustNow() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.trustThisDevice()
|
||||
onTrusted?.()
|
||||
} catch (err) {
|
||||
// Still at the cap somehow (a race) — surface it and let them revoke more.
|
||||
if (err.status === 409 && err.body?.devices) {
|
||||
setDevices(err.body.devices)
|
||||
setError('Still at the limit — revoke another device.')
|
||||
} else {
|
||||
setError('Could not trust this device. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlay} role="dialog" aria-modal="true" aria-label="Trusted-device limit reached">
|
||||
<div style={card}>
|
||||
<h2 className="display" style={{ margin: '0 0 8px', fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||
Trusted-device limit reached
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '0 0 16px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
You can trust up to {Math.max(devices.length, 1)} devices. Revoke one below to make room, then
|
||||
continue — or cancel to leave this device untrusted.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16, maxHeight: 240, overflowY: 'auto' }}>
|
||||
{devices.map((d) => (
|
||||
<div key={d.id} style={row}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{d.deviceName || d.platform || 'Device'}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.userAgent || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{devices.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>All devices revoked. You can trust this one now.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.84rem' }}>{error}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={trustNow} disabled={busy || !revokedAny} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Working…' : 'Trust this device'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={busy} className="pill">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const overlay = {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
zIndex: 1000,
|
||||
}
|
||||
const card = {
|
||||
width: '100%',
|
||||
maxWidth: 460,
|
||||
background: 'var(--panel, #1a1a1f)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
}
|
||||
const row = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 8,
|
||||
}
|
||||
137
client/src/components/security/TrustedDevicesPanel.jsx
Normal file
137
client/src/components/security/TrustedDevicesPanel.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
import TrustLimitModal from './TrustLimitModal.jsx'
|
||||
|
||||
// Self-service list of the devices allowed to skip the TOTP step at login (MFA
|
||||
// "Trust this device"). Uses the role-agnostic /auth/me/trusted-devices surface, so
|
||||
// the same panel serves players and staff. Shown only when 2FA is enabled — trust
|
||||
// is meaningless without a second factor to skip.
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—'
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export default function TrustedDevicesPanel() {
|
||||
const [devices, setDevices] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [capModal, setCapModal] = useState(null) // { devices } when the cap is hit
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setDevices(await api.myTrustedDevices())
|
||||
} catch {
|
||||
setError('Could not load your trusted devices.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function trustThis() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.trustThisDevice()
|
||||
setMsg('This device is now trusted.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (err.status === 409 && err.body?.error === 'trusted_device_limit') {
|
||||
setCapModal({ devices: err.body.devices || [] })
|
||||
} else {
|
||||
setError('Could not trust this device.')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(id) {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.revokeTrustedDevice(id)
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not revoke that device.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAll() {
|
||||
if (!window.confirm('Untrust every device? Each will require the full two-factor step at the next login.')) return
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.revokeAllTrustedDevices()
|
||||
setMsg('All devices untrusted.')
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not untrust devices.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!devices) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Trusted devices
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Devices you’ve trusted skip the authenticator step at login (your password is still required).
|
||||
Revoke any you don’t recognize.
|
||||
</p>
|
||||
|
||||
{devices.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{devices.map((d) => (
|
||||
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.userAgent || '—'} · last used {fmtDate(d.lastUsedAt)} · expires {fmtDate(d.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>No trusted devices yet.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={trustThis} disabled={busy} className="btn btn-sq">Trust this device</button>
|
||||
{devices.length > 0 && (
|
||||
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Untrust all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
|
||||
{capModal && (
|
||||
<TrustLimitModal
|
||||
devices={capModal.devices}
|
||||
onTrusted={() => { setCapModal(null); setMsg('This device is now trusted.'); load() }}
|
||||
onCancel={() => setCapModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
@@ -38,17 +38,22 @@ export function AuthProvider({ children }) {
|
||||
return data
|
||||
}, [])
|
||||
|
||||
// Step 2 for TOTP users: exchange the challenge + code for a real session.
|
||||
const loginTotp = useCallback(async (challenge, code) => {
|
||||
const data = await api.loginTotp(challenge, code)
|
||||
// Step 2 for TOTP users: exchange the challenge + a second factor (TOTP code or a
|
||||
// recovery code) for a real session. `extra` carries recoveryCode + the
|
||||
// trustDevice/deviceName opt-in. Returns the full payload ({ user,
|
||||
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
|
||||
const loginTotp = useCallback(async (challenge, code, extra) => {
|
||||
const data = await api.loginTotp(challenge, code, extra)
|
||||
setUser(data.user)
|
||||
return data.user
|
||||
return data
|
||||
}, [])
|
||||
|
||||
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
|
||||
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
|
||||
const ssoLoginTotp = useCallback(async (code) => {
|
||||
const data = await api.ssoLoginTotp(code)
|
||||
// an httpOnly cookie, so only the code is sent. `extra` carries the trustDevice/
|
||||
// deviceName opt-in. Returns the full payload ({ user, returnTo,
|
||||
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
|
||||
const ssoLoginTotp = useCallback(async (code, extra) => {
|
||||
const data = await api.ssoLoginTotp(code, extra)
|
||||
setUser(data.user)
|
||||
return data
|
||||
}, [])
|
||||
@@ -61,8 +66,15 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Memoized so consumers don't re-render on every provider render (the callbacks
|
||||
// are already stable via useCallback).
|
||||
const value = useMemo(
|
||||
() => ({ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }),
|
||||
[user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh],
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
import { applyThemeTokens } from '../lib/themeVars.js'
|
||||
|
||||
const SiteContext = createContext(null)
|
||||
|
||||
@@ -7,11 +8,16 @@ const SiteContext = createContext(null)
|
||||
export function SiteProvider({ children }) {
|
||||
const [settings, setSettings] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Whether a fetch has actually SUCCEEDED, as distinct from `loading` — which
|
||||
// also goes false when the request failed and we fell back to {}. The boot
|
||||
// theme handoff below turns on this distinction.
|
||||
const [settled, setSettled] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.publicSettings()
|
||||
setSettings(data || {})
|
||||
setSettled(true)
|
||||
} catch {
|
||||
setSettings({})
|
||||
} finally {
|
||||
@@ -23,25 +29,57 @@ export function SiteProvider({ children }) {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
const brand = settings.brand || {}
|
||||
const brand = useMemo(() => settings.brand || {}, [settings])
|
||||
|
||||
// Apply the admin's theme. The whole effective token set is resolved
|
||||
// server-side, so this only writes it and takes back what it wrote before —
|
||||
// see lib/themeVars.js for why the removal half matters. No theme block means
|
||||
// the admin never themed this instance, and the shipped :root stands.
|
||||
const appliedTokens = useRef([])
|
||||
useEffect(() => {
|
||||
appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current)
|
||||
// Take over from the shell's boot block. The server injects the same tokens
|
||||
// into <head> so a themed instance does not paint the shipped palette for a
|
||||
// frame first (utils/htmlShell.js); from here on this effect is the
|
||||
// authority, and leaving the block behind would mean a later reset removed
|
||||
// the inline properties only to reveal the stale block underneath.
|
||||
//
|
||||
// Gated on a SUCCESSFUL fetch, not merely a finished one: a failed request
|
||||
// leaves us with no theme at all, and dropping the block then would strip a
|
||||
// themed instance back to the shipped palette for no reason.
|
||||
if (settled) document.getElementById('theme-boot')?.remove()
|
||||
}, [settings.theme, settled])
|
||||
|
||||
// Apply the instance accent color to the CSS variable the theme is built on,
|
||||
// so branding flows to every `var(--accent)` at runtime (no rebuild).
|
||||
// so branding flows to every `var(--accent)` at runtime (no rebuild). This is
|
||||
// the *effective* accent — the admin theme overrides BRAND_ACCENT_COLOR
|
||||
// server-side (docs/website/THEMING_AND_NAV.md §4.5) — so it agrees with the
|
||||
// theme block rather than fighting it.
|
||||
//
|
||||
// Deliberately ordered after the theme effect and re-run on any theme change:
|
||||
// resetting a theme removes --accent from the token map, and this has to be
|
||||
// the write that lands last or an instance with a custom BRAND_ACCENT_COLOR
|
||||
// would drop to the stylesheet's default accent until the next reload.
|
||||
useEffect(() => {
|
||||
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
|
||||
}, [brand.accent])
|
||||
}, [brand.accent, settings.theme])
|
||||
|
||||
const value = {
|
||||
settings,
|
||||
loading,
|
||||
refresh,
|
||||
brand,
|
||||
mode: settings.site_mode || 'live',
|
||||
siteTitle: brand.name || settings.site_title || 'Runic Gateway',
|
||||
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
|
||||
contactEmail: brand.contactEmail || settings.contact_email || '',
|
||||
heroImage: brand.hero || '/assets/img/runic-emblem.png',
|
||||
}
|
||||
// Memoized so consumers don't re-render on every provider render (brand is a
|
||||
// fresh object each render, which would otherwise churn the context value).
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
settings,
|
||||
loading,
|
||||
refresh,
|
||||
brand,
|
||||
mode: settings.site_mode || 'live',
|
||||
siteTitle: brand.name || settings.site_title || 'Runic Gateway',
|
||||
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
|
||||
contactEmail: brand.contactEmail || settings.contact_email || '',
|
||||
heroImage: brand.hero || '/assets/img/runic-emblem.png',
|
||||
}),
|
||||
[settings, loading, refresh, brand],
|
||||
)
|
||||
|
||||
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
// 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) }
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// 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.
|
||||
|
||||
// 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) =>
|
||||
/^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test(
|
||||
r,
|
||||
),
|
||||
},
|
||||
{
|
||||
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 }
|
||||
}
|
||||
12
client/src/emailBlocks/index.js
Normal file
12
client/src/emailBlocks/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// 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'
|
||||
100
client/src/emailBlocks/registry.js
Normal file
100
client/src/emailBlocks/registry.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── 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(),
|
||||
}
|
||||
}
|
||||
272
client/src/emailBlocks/types.jsx
Normal file
272
client/src/emailBlocks/types.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
// 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>
|
||||
)
|
||||
},
|
||||
})
|
||||
100
client/src/lib/adminNav.js
Normal file
100
client/src/lib/adminNav.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// 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
|
||||
}
|
||||
348
client/src/lib/engagementRules.js
Normal file
348
client/src/lib/engagementRules.js
Normal file
@@ -0,0 +1,348 @@
|
||||
// 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`
|
||||
}
|
||||
@@ -67,6 +67,11 @@ 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,
|
||||
@@ -84,9 +89,9 @@ export function defaultLayout(teaser, name = 'Runic Gateway') {
|
||||
align: 'center',
|
||||
width: 760,
|
||||
lines: [
|
||||
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
|
||||
{ text: 'Private game server', 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 Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
||||
{ text: 'A private 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 },
|
||||
],
|
||||
},
|
||||
|
||||
252
client/src/lib/moduleAdmin.js
Normal file
252
client/src/lib/moduleAdmin.js
Normal file
@@ -0,0 +1,252 @@
|
||||
// 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)
|
||||
}
|
||||
506
client/src/lib/navOverrides.js
Normal file
506
client/src/lib/navOverrides.js
Normal file
@@ -0,0 +1,506 @@
|
||||
// Apply an admin's stored navigation overrides to a hardcoded NAV array.
|
||||
//
|
||||
// The three navs (public header, admin sidebar, player portal) stay declared in
|
||||
// code; this layer only reorders, relabels and hides what is already there.
|
||||
// See docs/website/THEMING_AND_NAV.md §7.
|
||||
//
|
||||
// **This is presentation, never authorization.** The override can carry
|
||||
// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else.
|
||||
// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or
|
||||
// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run
|
||||
// *after* this merge, unchanged, and remain the actual boundary. An override
|
||||
// saying `hidden: false` on a role-gated item still shows nothing to a viewer
|
||||
// whose role check fails: hiding is subtractive here, never additive.
|
||||
//
|
||||
// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string
|
||||
// label, a group that does not exist — is ignored rather than rejected, so a
|
||||
// stale or hand-edited settings row degrades to the code default instead of
|
||||
// rendering a broken nav.
|
||||
|
||||
// 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) {
|
||||
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
|
||||
}
|
||||
|
||||
// A stored override entry is usable only field by field: a bad `label` must not
|
||||
// discard a good `order` alongside it.
|
||||
function cleanEntry(raw, groupTitles) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const out = {}
|
||||
if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim()
|
||||
if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order
|
||||
if (raw.hidden === true) out.hidden = true
|
||||
// `group` may only name a section the base nav already declares. Anything else
|
||||
// — a renamed group, a typo, an invented category — is dropped, so an item can
|
||||
// never land in a header that does not exist.
|
||||
if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group
|
||||
return out
|
||||
}
|
||||
|
||||
// Sort by effective order, where an item the admin never reordered keeps its
|
||||
// index in the base array as its key. Two tie-breaks, in order: an explicit
|
||||
// order beats a coincidental index (the admin said "first", so first), and two
|
||||
// explicit orders stay in code order (the sort is stable).
|
||||
//
|
||||
// In practice the editor writes an order for every item in a list, the way
|
||||
// drag-and-drop reordering does, so ties are the stale-row case rather than the
|
||||
// normal one. They still have to resolve predictably.
|
||||
function byOrder(items) {
|
||||
return items
|
||||
.map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
|
||||
.map(({ item }) => {
|
||||
const { __order, ...rest } = item
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
// Apply label/hidden/order to one flat list, with the sort key parked on
|
||||
// `__order` for byOrder to consume.
|
||||
//
|
||||
// `keepHidden` is what the admin editor needs and the site must not have: the
|
||||
// editor has to render a hidden row in its right place so it can be un-hidden,
|
||||
// while a layout must simply not render it. Same merge either way, so the two
|
||||
// can never disagree about where an item sits.
|
||||
function mergeItems(items, entries, keepHidden = false) {
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.hidden && !keepHidden) continue
|
||||
// Spread the base item first so `to`, `roles`, `feature`, `icon` and `end`
|
||||
// survive verbatim — the override only ever lands on `label`.
|
||||
out.push({
|
||||
...item,
|
||||
...(o?.label ? { label: o.label } : {}),
|
||||
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
|
||||
__order: o?.order,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The stored overrides, cleaned and keyed, plus the group titles the base nav
|
||||
// declares. Shared by the merge and the editor so both read a row the same way.
|
||||
function readOverrides(baseNav, overrides, grouped) {
|
||||
const groupTitles = new Set(
|
||||
grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
|
||||
)
|
||||
const entries = new Map()
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return { entries, groupTitles }
|
||||
// Keyed by `to`, and only for a `to` the base nav actually declares. An
|
||||
// override for a route that no longer exists is dropped here, so deleting a
|
||||
// route in code can never leave a dangling override that does something
|
||||
// unexpected later.
|
||||
const known = new Set(
|
||||
grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to),
|
||||
)
|
||||
for (const [to, raw] of Object.entries(overrides)) {
|
||||
if (!known.has(to)) continue
|
||||
const entry = cleanEntry(raw, groupTitles)
|
||||
if (entry && Object.keys(entry).length > 0) entries.set(to, entry)
|
||||
}
|
||||
return { entries, groupTitles }
|
||||
}
|
||||
|
||||
// Move items whose override names a different existing section. Groups keep
|
||||
// their coded order — only membership and within-group order move.
|
||||
function regroup(baseNav, entries) {
|
||||
const moved = new Map() // destination title → items pulled in from elsewhere
|
||||
const kept = baseNav.map((g) => {
|
||||
const items = []
|
||||
for (const item of g.items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.group && o.group !== g.title) {
|
||||
if (!moved.has(o.group)) moved.set(o.group, [])
|
||||
moved.get(o.group).push(item)
|
||||
continue
|
||||
}
|
||||
items.push(item)
|
||||
}
|
||||
return { ...g, items }
|
||||
})
|
||||
return { kept, moved }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} baseNav the hardcoded nav — the source of truth for `to`,
|
||||
* `roles`, `feature`, `icon` and `end`
|
||||
* @param {object|null} overrides the parsed settings JSON, keyed by `to`, or
|
||||
* null when the admin never touched this nav
|
||||
* @returns {Array} a new array of the same shape, or `baseNav` itself when there
|
||||
* is nothing to apply
|
||||
*/
|
||||
export function applyNavOverrides(baseNav, overrides) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
// The untouched path, and the one that matters most: no row, a malformed row,
|
||||
// or a row with nothing usable in it all render the nav exactly as coded.
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav
|
||||
|
||||
const grouped = isGrouped(baseNav)
|
||||
const { entries } = readOverrides(baseNav, overrides, grouped)
|
||||
if (entries.size === 0) return baseNav
|
||||
|
||||
if (!grouped) return byOrder(mergeItems(baseNav, entries))
|
||||
|
||||
// Grouped: an item may also be moved into another *existing* titled section.
|
||||
const { kept, moved } = regroup(baseNav, entries)
|
||||
|
||||
return kept
|
||||
.map((g) => ({
|
||||
...g,
|
||||
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)),
|
||||
}))
|
||||
// A group whose every item was hidden must not leave an orphaned header.
|
||||
// AdminLayout drops empty groups again after its own role filter; doing it
|
||||
// here too keeps the util correct on its own.
|
||||
.filter((g) => g.items.length > 0)
|
||||
}
|
||||
|
||||
// ── The admin editor's round trip ────────────────────────────────────────
|
||||
//
|
||||
// Two functions, inverse to each other, kept in this file rather than beside the
|
||||
// editor screen so the thing that *writes* an override and the thing that
|
||||
// *applies* one can never drift: the rows the admin drags are produced by the
|
||||
// same merge the site renders, hidden ones included.
|
||||
|
||||
/**
|
||||
* The base nav plus its stored overrides, as editable rows — always in the
|
||||
* grouped shape, so one editor handles both navs.
|
||||
*
|
||||
* Unlike applyNavOverrides this keeps hidden rows (marked `hidden: true`, so
|
||||
* they can be un-hidden) and keeps empty groups (so something can be moved back
|
||||
* into one). Each row carries `defaultLabel`, which is what "reset this label"
|
||||
* restores and what the input shows as its placeholder.
|
||||
*
|
||||
* @param {Array} baseNav the hardcoded nav, flat or grouped
|
||||
* @param {object|null} overrides the parsed settings JSON
|
||||
* @returns {Array<{title: string|null, items: Array}>}
|
||||
*/
|
||||
export function buildNavRows(baseNav, overrides) {
|
||||
if (!Array.isArray(baseNav) || baseNav.length === 0) return []
|
||||
const grouped = isGrouped(baseNav)
|
||||
const { entries } = readOverrides(baseNav, overrides, grouped)
|
||||
|
||||
if (!grouped) {
|
||||
return [{ title: null, items: byOrder(mergeItems(baseNav, entries, true)) }]
|
||||
}
|
||||
const { kept, moved } = regroup(baseNav, entries)
|
||||
return kept.map((g) => ({
|
||||
...g,
|
||||
title: g.title ?? null,
|
||||
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries, true)),
|
||||
}))
|
||||
}
|
||||
|
||||
// Did the admin actually move anything? Comparing the edited sequence with the
|
||||
// coded one is what decides whether orders are written at all: an admin who only
|
||||
// renamed an item should not pin the position of every other one, or a route
|
||||
// added in code later would land in an arbitrary place.
|
||||
//
|
||||
// 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.
|
||||
function orderMatchesBase(groups, baseNav) {
|
||||
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
|
||||
const base = isGrouped(baseNav)
|
||||
? baseNav.map((g) => ({ title: g.title ?? null, items: g.items }))
|
||||
: [{ title: null, items: baseNav }]
|
||||
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
|
||||
const a = flatten(groups)
|
||||
const b = flatten(base.map((g) => ({ ...g, items: g.items.filter((i) => shown.has(i.to)) })))
|
||||
return a.length === b.length && a.every((v, i) => v === b[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows the admin has been editing, back as an overrides object to store.
|
||||
* Only differences from the code default are written — a field that matches the
|
||||
* default is absent, so the row stays a small statement of intent rather than a
|
||||
* snapshot of the nav.
|
||||
*
|
||||
* @param {Array} groups the editor's groups, in their current order
|
||||
* @param {Array} baseNav the hardcoded nav these rows came from
|
||||
* @param {object|null} stored the overrides as loaded, so entries for items
|
||||
* this admin could not see (role- or feature-gated out of their palette) are
|
||||
* carried through rather than silently dropped on save
|
||||
* @returns {object} the overrides to store — `{}` when nothing differs
|
||||
*/
|
||||
export function buildNavOverrides(groups, baseNav, stored = null) {
|
||||
if (!Array.isArray(groups) || !Array.isArray(baseNav)) return {}
|
||||
const grouped = isGrouped(baseNav)
|
||||
const baseItems = new Map(
|
||||
(grouped ? baseNav.flatMap((g) => g.items.map((i) => [i, g.title ?? null])) : baseNav.map((i) => [i, null])).map(
|
||||
([item, title]) => [item.to, { label: item.label, group: title }],
|
||||
),
|
||||
)
|
||||
|
||||
const out = {}
|
||||
// Carry through what this admin's palette never showed them. An entry for a
|
||||
// `to` the base nav no longer declares is NOT carried: dropping it is the
|
||||
// cleanup, and applyNavOverrides ignores it anyway.
|
||||
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
|
||||
if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
|
||||
for (const [to, entry] of Object.entries(stored)) {
|
||||
if (!shown.has(to) && baseItems.has(to) && entry && typeof entry === 'object') out[to] = entry
|
||||
}
|
||||
}
|
||||
|
||||
const writeOrder = !orderMatchesBase(groups, baseNav)
|
||||
for (const group of groups) {
|
||||
group.items.forEach((row, index) => {
|
||||
const base = baseItems.get(row.to)
|
||||
if (!base) return
|
||||
const entry = {}
|
||||
const label = typeof row.label === 'string' ? row.label.trim() : ''
|
||||
if (label && label !== base.label) entry.label = label
|
||||
if (row.hidden === true) entry.hidden = true
|
||||
if (grouped && (group.title ?? null) !== base.group && group.title) entry.group = group.title
|
||||
if (writeOrder) entry.order = index
|
||||
if (Object.keys(entry).length > 0) out[row.to] = entry
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── The public header: dropdown sections and added links ────────────────
|
||||
//
|
||||
// Phase 10. The public nav is the one nav an admin can restructure rather than
|
||||
// only reorder: they may create dropdown **sections**, drop coded entries into
|
||||
// them, and add **links** of their own to pages on this site.
|
||||
//
|
||||
// The invariant §7 rests on survives, and it survives structurally rather than
|
||||
// by vigilance: coded entries stay keyed by a `to` the base array must declare,
|
||||
// so an override still cannot invent a route or touch a `roles`/`feature` gate,
|
||||
// while everything that CAN name an arbitrary path lives in `links` where the
|
||||
// path rule is applied. An added link carries no gate of its own and needs none
|
||||
// — the page behind it enforces its own access, so a link to somewhere the
|
||||
// viewer cannot reach 403s exactly as typing the URL would.
|
||||
//
|
||||
// Stored shape (server/src/utils/navOverrides.js is the writer):
|
||||
// { items: {"<to>": {...}}, sections: [{id,label,order}], links: [{id,label,to,order,section}] }
|
||||
// A bare map is still read as the items map — unambiguous, because every item
|
||||
// key is a path and so can never be the string `items`.
|
||||
|
||||
function unwrapPublic(overrides) {
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
||||
return { items: {}, sections: [], links: [] }
|
||||
}
|
||||
const wrapped = overrides.items && typeof overrides.items === 'object' && !Array.isArray(overrides.items)
|
||||
const items = wrapped ? overrides.items : overrides
|
||||
const sections = wrapped && Array.isArray(overrides.sections) ? overrides.sections : []
|
||||
const links = wrapped && Array.isArray(overrides.links) ? overrides.links : []
|
||||
return { items, sections, links }
|
||||
}
|
||||
|
||||
// Forgiving, like every other read here: an entry that is not usable is dropped
|
||||
// and its neighbours kept.
|
||||
function readSections(sections) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const s of sections) {
|
||||
if (!s || typeof s !== 'object' || typeof s.id !== 'string' || seen.has(s.id)) continue
|
||||
if (typeof s.label !== 'string' || !s.label.trim()) continue
|
||||
seen.add(s.id)
|
||||
out.push({ id: s.id, label: s.label.trim(), order: typeof s.order === 'number' && Number.isFinite(s.order) ? s.order : undefined })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function readLinks(links, knownSections) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const l of links) {
|
||||
if (!l || typeof l !== 'object' || typeof l.id !== 'string' || seen.has(l.id)) continue
|
||||
if (typeof l.label !== 'string' || !l.label.trim()) continue
|
||||
// Same rule the server writes by. A stored value that would leave the origin
|
||||
// is dropped rather than rendered, so a hand-edited row cannot put an
|
||||
// off-site link in the header.
|
||||
if (typeof l.to !== 'string' || !l.to.startsWith('/') || l.to.startsWith('//') || /[\s<>"'\\]/.test(l.to)) continue
|
||||
seen.add(l.id)
|
||||
out.push({
|
||||
id: l.id,
|
||||
label: l.label.trim(),
|
||||
to: l.to,
|
||||
order: typeof l.order === 'number' && Number.isFinite(l.order) ? l.order : undefined,
|
||||
section: typeof l.section === 'string' && knownSections.has(l.section) ? l.section : null,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The public nav as a one-level tree of `{kind: 'item' | 'link' | 'section'}`.
|
||||
*
|
||||
* @param {Array} baseNav the hardcoded public NAV — still the only source of
|
||||
* `to`, `feature` and `end` for a coded entry
|
||||
* @param {object|null} overrides the parsed nav_public row
|
||||
* @param {{keepHidden?: boolean}} [opts] the editor keeps hidden entries so
|
||||
* they can be un-hidden, and gets `defaultLabel` for the reset affordance;
|
||||
* the header must not render them at all
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {}) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
const { items, sections: rawSections, links: rawLinks } = unwrapPublic(overrides)
|
||||
|
||||
const sections = readSections(rawSections)
|
||||
const knownSections = new Set(sections.map((s) => s.id))
|
||||
const links = readLinks(rawLinks, knownSections)
|
||||
|
||||
// Coded entries, keyed by a `to` the base array declares. Anything else in the
|
||||
// map is dropped here, exactly as in applyNavOverrides.
|
||||
const known = new Set(baseNav.map((i) => i.to))
|
||||
const entries = new Map()
|
||||
for (const [to, raw] of Object.entries(items)) {
|
||||
if (!known.has(to)) continue
|
||||
const entry = cleanEntry(raw, new Set())
|
||||
if (!entry) continue
|
||||
if (typeof raw?.section === 'string' && knownSections.has(raw.section)) entry.section = raw.section
|
||||
entries.set(to, entry)
|
||||
}
|
||||
|
||||
const nodes = []
|
||||
baseNav.forEach((item, index) => {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.hidden && !keepHidden) return
|
||||
nodes.push({
|
||||
kind: 'item',
|
||||
...item,
|
||||
...(o?.label ? { label: o.label } : {}),
|
||||
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
|
||||
section: o?.section ?? null,
|
||||
__order: o?.order,
|
||||
__index: index,
|
||||
})
|
||||
})
|
||||
// An admin-created entity with no stored order appends after the coded ones,
|
||||
// in creation order, rather than jumping to the front on a 0 default.
|
||||
let next = baseNav.length
|
||||
for (const section of sections) {
|
||||
nodes.push({ kind: 'section', id: section.id, label: section.label, section: null, __order: section.order, __index: next++ })
|
||||
}
|
||||
for (const link of links) {
|
||||
nodes.push({ kind: 'link', id: link.id, to: link.to, label: link.label, section: link.section, __order: link.order, __index: next++ })
|
||||
}
|
||||
|
||||
const place = (list) =>
|
||||
list
|
||||
.map((n) => ({ n, key: n.__order ?? n.__index, explicit: n.__order !== undefined }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
|
||||
.map(({ n }) => {
|
||||
const { __order, __index, section, ...rest } = n
|
||||
return rest
|
||||
})
|
||||
|
||||
const top = place(nodes.filter((n) => n.kind === 'section' || !n.section))
|
||||
return top.map((node) =>
|
||||
node.kind === 'section'
|
||||
? { ...node, items: place(nodes.filter((n) => n.section === node.id)) }
|
||||
: node,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Added links carry no gate, so they are always visible — see the note above.
|
||||
*
|
||||
* @param {Array} tree from buildPublicNav
|
||||
* @param {(item: object) => boolean} isVisible applied to coded items only
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function pruneNav(tree, isVisible) {
|
||||
if (!Array.isArray(tree)) return []
|
||||
const keep = (node) => node.kind !== 'item' || isVisible(node)
|
||||
return tree
|
||||
.map((node) => (node.kind === 'section' ? { ...node, items: (node.items || []).filter(keep) } : node))
|
||||
.filter((node) => (node.kind === 'section' ? node.items.length > 0 : keep(node)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's tree back as a nav_public value to store.
|
||||
*
|
||||
* Returns the **bare items map** when there are no sections and no added links,
|
||||
* so a nav that does not use this feature stores exactly what phases 6-8 stored.
|
||||
*
|
||||
* @param {Array} tree the editor's current tree
|
||||
* @param {Array} baseNav the hardcoded public NAV
|
||||
* @param {object|null} stored as loaded, so an entry for a feature-gated item
|
||||
* this admin could not see survives their save
|
||||
* @returns {object} `{}` when nothing differs from the code default
|
||||
*/
|
||||
export function buildPublicNavOverrides(tree, baseNav, stored = null) {
|
||||
if (!Array.isArray(tree) || !Array.isArray(baseNav)) return {}
|
||||
const baseLabels = new Map(baseNav.map((i) => [i.to, i.label]))
|
||||
const sections = []
|
||||
const links = []
|
||||
const items = {}
|
||||
|
||||
// Flatten to (node, containerId, indexInContainer), which is all the writer
|
||||
// needs: a section's own position is its index in the top-level list.
|
||||
const placed = []
|
||||
tree.forEach((node, index) => {
|
||||
placed.push({ node, section: null, index })
|
||||
if (node.kind === 'section') (node.items || []).forEach((child, i) => placed.push({ node: child, section: node.id, index: i }))
|
||||
})
|
||||
|
||||
// Orders are written whenever this nav has any structure of its own: a section
|
||||
// exists only because the admin put it somewhere, so its position is never
|
||||
// "whatever the code says". Without sections the rule is phase 6-8's — write
|
||||
// orders only if the sequence actually moved.
|
||||
const hasStructure = tree.some((n) => n.kind === 'section' || n.kind === 'link')
|
||||
const shown = new Set(tree.flatMap((n) => (n.kind === 'section' ? (n.items || []) : [n])).filter((n) => n.kind === 'item').map((n) => n.to))
|
||||
const sequence = tree.filter((n) => n.kind === 'item').map((n) => n.to)
|
||||
const baseSequence = baseNav.filter((i) => shown.has(i.to)).map((i) => i.to)
|
||||
const moved = sequence.length !== baseSequence.length || sequence.some((to, i) => to !== baseSequence[i])
|
||||
const writeOrder = hasStructure || moved
|
||||
|
||||
for (const { node, section, index } of placed) {
|
||||
if (node.kind === 'section') {
|
||||
sections.push({ id: node.id, label: (node.label || '').trim() || 'Section', ...(writeOrder ? { order: index } : {}) })
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'link') {
|
||||
links.push({
|
||||
id: node.id,
|
||||
label: (node.label || '').trim() || node.to,
|
||||
to: node.to,
|
||||
...(section ? { section } : {}),
|
||||
...(writeOrder ? { order: index } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const entry = {}
|
||||
const label = typeof node.label === 'string' ? node.label.trim() : ''
|
||||
if (label && label !== baseLabels.get(node.to)) entry.label = label
|
||||
if (node.hidden === true) entry.hidden = true
|
||||
if (section) entry.section = section
|
||||
if (writeOrder) entry.order = index
|
||||
if (Object.keys(entry).length > 0) items[node.to] = entry
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
if (sections.length === 0 && links.length === 0) return items
|
||||
const out = { items }
|
||||
if (sections.length) out.sections = sections
|
||||
if (links.length) out.links = links
|
||||
return out
|
||||
}
|
||||
|
||||
export default applyNavOverrides
|
||||
21
client/src/lib/notificationPaths.js
Normal file
21
client/src/lib/notificationPaths.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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'
|
||||
26
client/src/lib/pageShell.js
Normal file
26
client/src/lib/pageShell.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// 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`
|
||||
}
|
||||
32
client/src/lib/settingsJson.js
Normal file
32
client/src/lib/settingsJson.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Parse a JSON-valued settings row, client side.
|
||||
//
|
||||
// The counterpart to server/src/utils/settingsJson.js, and deliberately the same
|
||||
// three lines of judgement: `settings.value` is TEXT, so theme_visual,
|
||||
// brand_assets and the three nav_* keys all arrive as strings, and a malformed
|
||||
// or wrong-shaped one must read as **absent** — the surface falls back to its
|
||||
// BRAND_* env / theme.css / hardcoded NAV default — never as an error and never
|
||||
// as a half-applied object.
|
||||
//
|
||||
// THEMING_AND_NAV.md §4.4 planned this "with its first consumer"; that consumer
|
||||
// is the public header reading nav_public. `parseLayout` in heroLayout.js keeps
|
||||
// its own version check because it validates a shape, not just a shape's kind.
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} str the raw stored value
|
||||
* @returns {object|null} the parsed object, or null when absent/malformed
|
||||
*/
|
||||
export function parseJsonSetting(str) {
|
||||
if (typeof str !== 'string' || str === '') return null
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(str)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
|
||||
// every consumer of these keys as a syntax error is.
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
export default parseJsonSetting
|
||||
@@ -1,106 +0,0 @@
|
||||
// Shared formatting for shard events — used by the public Shard page, the
|
||||
// Activity feed, and the admin live feed. One place decides how each kind reads
|
||||
// and which category/badge it belongs to.
|
||||
|
||||
function nameOf(who) {
|
||||
if (!who) return 'Someone'
|
||||
if (typeof who === 'string') return who
|
||||
return who.name || who.acct || 'Someone'
|
||||
}
|
||||
|
||||
const n = (v) => Number(v || 0).toLocaleString()
|
||||
|
||||
// A one-line human description of an event. Accepts either a stored event
|
||||
// (with .payload) or a raw live frame (fields at top level).
|
||||
export function describe(ev) {
|
||||
const p = ev.payload || ev
|
||||
switch (ev.kind) {
|
||||
case 'vendor.sale':
|
||||
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp`
|
||||
case 'player.death':
|
||||
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
|
||||
case 'player.murdered':
|
||||
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
|
||||
case 'mob.killed':
|
||||
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
|
||||
case 'skill.gain':
|
||||
return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}`
|
||||
case 'fame.change':
|
||||
return `${nameOf(p.who)}’s fame changed to ${n(p.new)}`
|
||||
case 'karma.change':
|
||||
return `${nameOf(p.who)}’s karma changed to ${n(p.new)}`
|
||||
case 'quest.complete':
|
||||
return `${nameOf(p.who)} completed “${p.quest}”`
|
||||
case 'house.decay':
|
||||
return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? ` — ${p.region}` : ''}`
|
||||
case 'mob.login':
|
||||
return `${nameOf(p.who)} entered the world`
|
||||
case 'mob.logout':
|
||||
return `${nameOf(p.who)} left the world`
|
||||
case 'economy.supply':
|
||||
return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`
|
||||
case 'server.hello':
|
||||
return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`
|
||||
case 'server.shutdown':
|
||||
return 'Shard shut down'
|
||||
case 'server.crashed':
|
||||
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
|
||||
case 'champ.update': {
|
||||
const where = p.name || p.type || 'A champion spawn'
|
||||
if (p.status === 'active' && p.bossUp) return `${where}: boss is up${p.boss ? ` (${p.boss})` : ''}`
|
||||
if (p.status === 'active') return `${where} is active${p.level != null ? ` — level ${p.level}` : ''}`
|
||||
if (p.status === 'cooldown') return `${where} is on cooldown`
|
||||
return `${where} is ${p.status || 'idle'}`
|
||||
}
|
||||
case 'champ.remove':
|
||||
return `A champion spawn ended`
|
||||
// Support (help-page) queue + in-game moderation (admin channel only)
|
||||
case 'page.new':
|
||||
return `New ${p.type || 'help'} page from ${nameOf(p.sender)}`
|
||||
case 'page.updated':
|
||||
return `Help page from ${nameOf(p.sender)} updated${p.handled ? ' (claimed)' : ''}`
|
||||
case 'page.closed':
|
||||
return `Help page ${p.pageId || ''} closed`
|
||||
case 'admin.audit':
|
||||
return `${p.actor || 'Staff'} ${p.action || 'acted'}${p.target ? ` on ${p.target}` : ''}${p.origin ? ` [${p.origin}]` : ''}`
|
||||
// Staff / sensitive (admin channel only)
|
||||
case 'audit.set':
|
||||
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`
|
||||
case 'audit.command':
|
||||
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}`
|
||||
case 'cheat.fastwalk':
|
||||
return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}`
|
||||
case 'account.login.attempt':
|
||||
return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}`
|
||||
case 'gold.change':
|
||||
return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}`
|
||||
default:
|
||||
return ev.kind
|
||||
}
|
||||
}
|
||||
|
||||
// Category grouping for the filter tabs.
|
||||
// Vendor sales are intentionally NOT a public category — they are owner-private
|
||||
// (a linked player sees their own under the portal). The admin live feed still
|
||||
// describes vendor.sale via describe() below.
|
||||
export const CATEGORIES = [
|
||||
{ id: 'all', label: 'All', kinds: null },
|
||||
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
||||
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
||||
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
||||
]
|
||||
|
||||
const CATEGORY_OF = (() => {
|
||||
const m = {}
|
||||
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
|
||||
return m
|
||||
})()
|
||||
|
||||
export function categoryOf(kind) {
|
||||
return CATEGORY_OF[kind] || 'other'
|
||||
}
|
||||
|
||||
// Short badge label for a kind (the part after the dot, title-cased-ish).
|
||||
export function kindLabel(kind) {
|
||||
return String(kind || '').replace(/[._]/g, ' ')
|
||||
}
|
||||
100
client/src/lib/teamActivity.js
Normal file
100
client/src/lib/teamActivity.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// 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.'
|
||||
}
|
||||
140
client/src/lib/teamAdmin.js
Normal file
140
client/src/lib/teamAdmin.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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'})`,
|
||||
}
|
||||
}
|
||||
85
client/src/lib/teamForum.js
Normal file
85
client/src/lib/teamForum.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// 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(' · ')
|
||||
}
|
||||
103
client/src/lib/teamIntegrations.js
Normal file
103
client/src/lib/teamIntegrations.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// 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)),
|
||||
}
|
||||
}
|
||||
112
client/src/lib/teamVoice.js
Normal file
112
client/src/lib/teamVoice.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// 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.`
|
||||
}
|
||||
47
client/src/lib/themeVars.js
Normal file
47
client/src/lib/themeVars.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Apply the server-resolved theme to the document as CSS custom properties.
|
||||
//
|
||||
// The effective token set is resolved server-side and arrives on
|
||||
// `settings.theme` (see server/src/utils/themeResolve.js). The client's only
|
||||
// job is to write it onto <html> — and, crucially, to take back what it wrote
|
||||
// last time, which is the part with actual logic and the reason this lives in
|
||||
// its own testable module.
|
||||
//
|
||||
// Why removal matters: an admin who resets the theme, or switches from a preset
|
||||
// that sets --bg to one that does not, gets a payload that no longer mentions
|
||||
// that variable. Inline properties are not cleared by writing a smaller object
|
||||
// over them, so without an explicit removeProperty the old value would stick
|
||||
// until a reload. That would make "Reset to defaults" look broken.
|
||||
//
|
||||
// Everything written here is a value the server validated against a closed set
|
||||
// (hex color, curated font stack, bounded px length, listed shadow). The client
|
||||
// deliberately does not re-validate — it would be a second, drifting authority.
|
||||
// It does refuse anything that is not a `--custom-property`, which is the one
|
||||
// check that costs nothing and stops a token map from reaching an ordinary CSS
|
||||
// property.
|
||||
|
||||
const CUSTOM_PROPERTY = /^--[a-zA-Z0-9-_]+$/
|
||||
|
||||
/**
|
||||
* @param {CSSStyleDeclaration} style usually document.documentElement.style
|
||||
* @param {Record<string, string>|null|undefined} tokens the new theme, or
|
||||
* null/absent for "no admin theme" — which clears everything previously set
|
||||
* @param {string[]} [applied] the keys this function wrote last time
|
||||
* @returns {string[]} the keys now applied, to pass back on the next call
|
||||
*/
|
||||
export function applyThemeTokens(style, tokens, applied = []) {
|
||||
const next = []
|
||||
if (tokens && typeof tokens === 'object') {
|
||||
for (const [name, value] of Object.entries(tokens)) {
|
||||
if (!CUSTOM_PROPERTY.test(name) || typeof value !== 'string' || value === '') continue
|
||||
style.setProperty(name, value)
|
||||
next.push(name)
|
||||
}
|
||||
}
|
||||
// Take back only what we set ourselves. Anything else on the element's inline
|
||||
// style belongs to someone else (SiteContext's own --accent line, a future
|
||||
// feature) and is not ours to clear.
|
||||
for (const name of applied) {
|
||||
if (!next.includes(name)) style.removeProperty(name)
|
||||
}
|
||||
return next
|
||||
}
|
||||
56
client/src/lib/useNavOverrides.js
Normal file
56
client/src/lib/useNavOverrides.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
import { parseJsonSetting } from './settingsJson.js'
|
||||
|
||||
// The nav overrides for the two authenticated layouts (THEMING_AND_NAV.md §4.2).
|
||||
//
|
||||
// `nav_public` rides along in the public settings payload, but `nav_admin` and
|
||||
// `nav_player` deliberately do not: an anonymous visitor has no use for either,
|
||||
// and the admin nav's labels describe the shape of the admin surface. Their
|
||||
// owners read them from GET /api/v1/settings/nav, which any signed-in account
|
||||
// may call — AdminLayout renders for editors and moderators, who cannot reach
|
||||
// GET /admin/settings at all.
|
||||
//
|
||||
// Failing quiet is the whole posture: a request that errors, a malformed row and
|
||||
// "not fetched yet" are the same state to the caller, `{}`, which
|
||||
// applyNavOverrides turns into the coded nav. A sidebar must never blink empty
|
||||
// because a settings call was slow.
|
||||
|
||||
// One module-level copy, so the second layout to mount renders the nav it
|
||||
// already knows rather than flashing the coded one, and so the nav editor can
|
||||
// push its save into the sidebar the admin is looking at without a reload.
|
||||
let cache = {}
|
||||
const subscribers = new Set()
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.navSettings()
|
||||
cache = {
|
||||
nav_admin: parseJsonSetting(data?.nav_admin),
|
||||
nav_player: parseJsonSetting(data?.nav_player),
|
||||
}
|
||||
subscribers.forEach((fn) => fn(cache))
|
||||
} catch {
|
||||
/* the coded nav is the fallback, and it is already on screen */
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Re-read the rows after a save, so the live sidebar catches up at once. */
|
||||
export function refreshNavOverrides() {
|
||||
return load()
|
||||
}
|
||||
|
||||
export function useNavOverrides() {
|
||||
const [overrides, setOverrides] = useState(cache)
|
||||
|
||||
useEffect(() => {
|
||||
subscribers.add(setOverrides)
|
||||
load()
|
||||
return () => subscribers.delete(setOverrides)
|
||||
}, [])
|
||||
|
||||
return overrides
|
||||
}
|
||||
|
||||
export default useNavOverrides
|
||||
@@ -1,54 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -2,12 +2,145 @@ import React from 'react'
|
||||
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'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
// 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).
|
||||
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) ───────────────────────────────────
|
||||
//
|
||||
// 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.
|
||||
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>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
mount()
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', mount, { once: true })
|
||||
}
|
||||
|
||||
68
client/src/modules/Slot.jsx
Normal file
68
client/src/modules/Slot.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// ── <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>
|
||||
}
|
||||
96
client/src/modules/TeamActivityFeed.jsx
Normal file
96
client/src/modules/TeamActivityFeed.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
754
client/src/modules/TeamForumPanel.jsx
Normal file
754
client/src/modules/TeamForumPanel.jsx
Normal file
@@ -0,0 +1,754 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
102
client/src/modules/TeamNotifyToggle.jsx
Normal file
102
client/src/modules/TeamNotifyToggle.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
56
client/src/modules/featureGate.js
Normal file
56
client/src/modules/featureGate.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// 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
|
||||
65
client/src/modules/features.jsx
Normal file
65
client/src/modules/features.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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
|
||||
174
client/src/modules/nav.js
Normal file
174
client/src/modules/nav.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// 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
|
||||
348
client/src/modules/registry.js
Normal file
348
client/src/modules/registry.js
Normal file
@@ -0,0 +1,348 @@
|
||||
// ── 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, 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 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`.
|
||||
|
||||
const routes = { public: [], admin: [], player: [] }
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const providers = new Map()
|
||||
// slot name → { Component, filledBy }.
|
||||
const slots = new Map()
|
||||
const registered = new Set()
|
||||
|
||||
const AREAS = ['public', 'admin', 'player']
|
||||
|
||||
function assertArea(area, call) {
|
||||
if (!AREAS.includes(area)) throw new Error(`${call}: unknown area "${area}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route components, by area.
|
||||
*
|
||||
* @param {string} id the module id — the URL segment its routes are namespaced under
|
||||
* @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).
|
||||
*/
|
||||
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.
|
||||
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||||
routes[area].push({ ...route, path, moduleId: id })
|
||||
}
|
||||
}
|
||||
registered.add(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?, icon?}>}} 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.
|
||||
*/
|
||||
export function registerFeatureProvider(id, namespace, hook) {
|
||||
providers.set(namespace, { id, hook })
|
||||
registered.add(id)
|
||||
}
|
||||
|
||||
// ── Extension slots (§3.7) ─────────────────────────────────────────────────
|
||||
//
|
||||
// The client twin of the server's declareSlot/registerExtension, and the same
|
||||
// rule in both halves: core declares a slot, ONLY core declares one, and at most
|
||||
// one module fills it. Core renders `<Slot 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).
|
||||
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 registeredIds = () => [...registered]
|
||||
|
||||
/** Test seam. Nothing in the app calls this — there is no unregistering. */
|
||||
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()
|
||||
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,
|
||||
registeredIds,
|
||||
}
|
||||
111
client/src/modules/shared.js
Normal file
111
client/src/modules/shared.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// ── 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 (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.
|
||||
|
||||
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.
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
|
||||
import { registry } from './registry.js'
|
||||
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'
|
||||
|
||||
// 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.
|
||||
const ui = {
|
||||
PublicLayout,
|
||||
PageHeader,
|
||||
Loading,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
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 }
|
||||
|
||||
/**
|
||||
* 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,
|
||||
react,
|
||||
reactDom,
|
||||
router,
|
||||
jsxRuntime,
|
||||
registry,
|
||||
ui: Object.freeze(ui),
|
||||
api: Object.freeze(api),
|
||||
})
|
||||
return window.__rg
|
||||
}
|
||||
61
client/src/modules/version.js
Normal file
61
client/src/modules/version.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// 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.
|
||||
//
|
||||
// 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.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.8.0'
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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).
|
||||
@@ -37,14 +44,26 @@ 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 IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></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 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>
|
||||
|
||||
// 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`
|
||||
// (when present) matches server-side enforcement so the sidebar never shows a
|
||||
// link that would 403; an item without `roles` is visible to everyone.
|
||||
// Moderators are further confined to just their section + account (see below).
|
||||
const NAV = [
|
||||
//
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here:
|
||||
// the editor may relabel, reorder, hide and regroup, and `roles` is never its to
|
||||
// touch (§7) — navItemVisibleTo below is the filter that still decides.
|
||||
export const NAV = [
|
||||
{
|
||||
items: [
|
||||
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
||||
@@ -64,8 +83,36 @@ const NAV = [
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/houses', label: 'Houses', icon: IconShard, 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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -74,16 +121,25 @@ 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/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
|
||||
// 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/account', label: 'Account', icon: IconUser },
|
||||
],
|
||||
},
|
||||
@@ -91,6 +147,27 @@ const NAV = [
|
||||
|
||||
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
||||
|
||||
// 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 —
|
||||
// this is the third guard, and the one that also covers a row edited straight
|
||||
// in the database. Cheap, and it makes "cannot be hidden" true without
|
||||
// qualification.
|
||||
const UNHIDEABLE = '/admin/navigation'
|
||||
|
||||
function keepEditorReachable(overrides) {
|
||||
const entry = overrides?.[UNHIDEABLE]
|
||||
if (!entry || entry.hidden !== true) return overrides
|
||||
const { hidden, ...rest } = entry
|
||||
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 }
|
||||
|
||||
const TITLES = {
|
||||
'/admin': 'Dashboard',
|
||||
'/admin/posts': 'Posts',
|
||||
@@ -99,18 +176,47 @@ const TITLES = {
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/moderation/appeals': 'Appeals',
|
||||
'/admin/shard-ops': 'In-Game Ops',
|
||||
'/admin/houses': 'House Registry',
|
||||
'/admin/moderation/reports': 'Reports',
|
||||
'/admin/teams': 'Teams',
|
||||
'/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/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',
|
||||
}
|
||||
|
||||
// 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/users/')) return 'User'
|
||||
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
|
||||
return 'Admin'
|
||||
}
|
||||
|
||||
const navBtnBase = {
|
||||
@@ -129,35 +235,43 @@ const navBtnBase = {
|
||||
export default function AdminLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { mode, siteTitle } = useSite()
|
||||
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] ||
|
||||
(location.pathname.startsWith('/admin/moderation')
|
||||
? 'Moderation'
|
||||
: location.pathname.startsWith('/admin/characters')
|
||||
? 'My Characters'
|
||||
: location.pathname.startsWith('/admin/users/')
|
||||
? 'User'
|
||||
: 'Admin')
|
||||
TITLES[location.pathname] || moduleTitle(baseNav, 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)'
|
||||
|
||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||
// own account security.
|
||||
const isModerator = user?.role === 'moderator'
|
||||
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||
const visible = (item) => {
|
||||
if (item.roles && !item.roles.includes(user?.role)) return false
|
||||
if (isModerator) return MOD_PATHS.includes(item.to)
|
||||
return true
|
||||
}
|
||||
// Drop items the current role can't see, then drop any now-empty group so an
|
||||
// empty category header never renders.
|
||||
const navGroups = NAV
|
||||
.map((g) => ({ ...g, items: g.items.filter(visible) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
|
||||
// An admin may relabel, reorder, hide and regroup these rows from Admin →
|
||||
// Navigation. The merge runs FIRST and the role filter after it, so the filter
|
||||
// stays the boundary: an override cannot show a moderator a row their role
|
||||
// gate hides, whatever it says. With no stored row applyNavOverrides returns
|
||||
// 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)),
|
||||
}))
|
||||
// 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],
|
||||
)
|
||||
|
||||
// Accordion: track which titled categories are collapsed. Persist across
|
||||
// reloads; default all-open. The group holding the active route auto-opens.
|
||||
@@ -183,17 +297,22 @@ 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
|
||||
const p = location.pathname
|
||||
const allowed =
|
||||
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
|
||||
if (!allowed) {
|
||||
if (!isAllowedPath(location.pathname, allowed)) {
|
||||
navigate('/admin/moderation', { replace: true })
|
||||
}
|
||||
}, [isModerator, location.pathname, navigate])
|
||||
}, [isModerator, location.pathname, navigate, allowed])
|
||||
|
||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||
useEffect(() => {
|
||||
@@ -224,6 +343,7 @@ export default function AdminLayout() {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<BrandLogo height={24} />
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
@@ -236,7 +356,7 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||
{navGroups.map((group, gi) => {
|
||||
{navGroups.map((group) => {
|
||||
const links = group.items.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
@@ -258,7 +378,7 @@ export default function AdminLayout() {
|
||||
// Untitled groups (Dashboard, Account) render their links directly.
|
||||
if (!group.title) {
|
||||
return (
|
||||
<div key={`g${gi}`} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div key={group.items[0]?.to || 'group'} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{links}
|
||||
</div>
|
||||
)
|
||||
@@ -337,6 +457,11 @@ 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>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
@@ -52,6 +54,9 @@ export default function AdminLogin() {
|
||||
const [challenge, setChallenge] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [ssoTotp, setSsoTotp] = useState(false)
|
||||
const [trustDevice, setTrustDevice] = useState(false)
|
||||
const [useRecovery, setUseRecovery] = useState(false)
|
||||
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest } when the cap is hit
|
||||
|
||||
// SSO providers to offer (empty if none configured) + any error the callback
|
||||
// bounced us back with (?sso_error=...).
|
||||
@@ -119,19 +124,34 @@ export default function AdminLogin() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (ssoTotp) {
|
||||
const { returnTo } = await ssoLoginTotp(code)
|
||||
navigate(returnTo || '/admin', { replace: true })
|
||||
// Trust works on the SSO second factor exactly as it does on the password
|
||||
// one — the IdP already proved the first factor.
|
||||
const data = await ssoLoginTotp(code.trim(), { trustDevice })
|
||||
const to = data.returnTo || '/admin'
|
||||
if (data.trustLimitReached) {
|
||||
setTrustLimit({ devices: data.devices || [], dest: to })
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
navigate(to, { replace: true })
|
||||
} else {
|
||||
const u = await loginTotp(challenge, code)
|
||||
navigate(destFor(u), { replace: true })
|
||||
const entered = code.trim()
|
||||
const data = await loginTotp(challenge, useRecovery ? '' : entered, {
|
||||
recoveryCode: useRecovery ? entered : undefined,
|
||||
trustDevice,
|
||||
})
|
||||
const to = destFor(data.user)
|
||||
if (data.trustLimitReached) {
|
||||
setTrustLimit({ devices: data.devices || [], dest: to })
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
navigate(to, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||
setError(
|
||||
expired
|
||||
? 'Your verification session expired. Please sign in again.'
|
||||
: 'Invalid verification code.',
|
||||
)
|
||||
const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
|
||||
setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
|
||||
setBusy(false)
|
||||
if (expired) {
|
||||
setStage('creds')
|
||||
@@ -140,6 +160,10 @@ export default function AdminLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
let submitLabel = 'Sign in'
|
||||
if (busy) submitLabel = 'Signing in…'
|
||||
else if (stage === 'totp') submitLabel = 'Verify'
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
@@ -158,6 +182,10 @@ export default function AdminLogin() {
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{/* Stacked above the moon rather than beside it: this layout is
|
||||
centered text, and a flex row here would change the block's
|
||||
height on instances with no logo. */}
|
||||
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
@@ -219,22 +247,42 @@ export default function AdminLogin() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Authentication code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
placeholder="6-digit code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
Enter the code from your authenticator app.
|
||||
</span>
|
||||
</label>
|
||||
<>
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode={useRecovery ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
placeholder={useRecovery ? 'xxxxx-xxxxx' : '6-digit code'}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
|
||||
</span>
|
||||
</label>
|
||||
{/* Offered on the SSO second factor too — the trust is on the device,
|
||||
not on how the first factor was proved. */}
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||
Trust this device for 30 days (skip the code next time)
|
||||
</label>
|
||||
{/* Recovery codes remain password-login only: the SSO second step
|
||||
verifies an authenticator code against the staged challenge. */}
|
||||
{!ssoTotp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
|
||||
className="sans"
|
||||
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
|
||||
>
|
||||
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(error || (stage === 'creds' && ssoError)) && (
|
||||
@@ -249,7 +297,7 @@ export default function AdminLogin() {
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
||||
>
|
||||
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||
{submitLabel}
|
||||
</button>
|
||||
|
||||
{/* SSO providers — only on the credentials step, only if any are enabled. */}
|
||||
@@ -300,6 +348,14 @@ export default function AdminLogin() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{trustLimit && (
|
||||
<TrustLimitModal
|
||||
devices={trustLimit.devices}
|
||||
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
|
||||
onCancel={() => navigate(trustLimit.dest, { replace: true })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
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
|
||||
@@ -22,7 +26,7 @@ function LinkedAccounts() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.admin.linkedIdentities(),
|
||||
api.myIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
@@ -41,7 +45,7 @@ function LinkedAccounts() {
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
try {
|
||||
await api.admin.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -123,14 +127,15 @@ export default function AccountAdmin() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Enrollment state.
|
||||
const [setup, setSetup] = useState(null) // { qr, otpauthUrl }
|
||||
const [setup, setSetup] = useState(null) // fields qr and otpauthUrl once enrolling
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setAccount(await api.admin.getAccount())
|
||||
setAccount(await api.myAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
@@ -150,7 +155,7 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
setSetup(await api.admin.totpSetup())
|
||||
setSetup(await api.totpSetup())
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not start setup.')
|
||||
@@ -164,9 +169,10 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.totpEnable(code.trim())
|
||||
const res = await api.totpEnable(code.trim())
|
||||
setSetup(null)
|
||||
setCode('')
|
||||
setNewCodes(res?.recoveryCodes || null)
|
||||
setMsg('Two-factor authentication is now enabled.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
@@ -181,7 +187,7 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.totpDisable(code.trim())
|
||||
await api.totpDisable(code.trim())
|
||||
setCode('')
|
||||
setMsg('Two-factor authentication has been disabled.')
|
||||
await load()
|
||||
@@ -302,6 +308,25 @@ export default function AccountAdmin() {
|
||||
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
|
||||
{/* One-time recovery codes shown right after enabling 2FA. */}
|
||||
{newCodes && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trusted devices + recovery-code management, only relevant with 2FA on. */}
|
||||
{enabled && (
|
||||
<>
|
||||
<TrustedDevicesPanel />
|
||||
<RecoveryCodesPanel hasPassword={account?.has_password !== false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default function Appeals() {
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
const [busyId, setBusyId] = useState('')
|
||||
const [resolving, setResolving] = useState(null) // the appeal being resolved
|
||||
const [notice, setNotice] = useState(null) // { text, tone }
|
||||
const [notice, setNotice] = useState(null) // fields text and tone
|
||||
|
||||
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
|
||||
const { loading, error, data } = useAsync(
|
||||
@@ -210,6 +210,8 @@ function ResolveModal({ appeal, onClose, onResolved }) {
|
||||
}
|
||||
}
|
||||
|
||||
const verb = status === 'approved' ? 'approved' : 'denied'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Resolve appeal — ${appeal.action_target_tag || appeal.discord_user_id}`}
|
||||
@@ -221,7 +223,7 @@ function ResolveModal({ appeal, onClose, onResolved }) {
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={submit} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : `Mark ${status === 'approved' ? 'approved' : 'denied'}`}
|
||||
{busy ? 'Saving…' : `Mark ${verb}`}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
|
||||
358
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal file
358
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
||||
import BrandAssetsPanel from './BrandAssetsPanel.jsx'
|
||||
|
||||
// Admin · Appearance — the theme and brand-asset halves of
|
||||
// docs/website/THEMING_AND_NAV.md (phases 3-5). The nav builder is phase 7 and
|
||||
// gets its own screen.
|
||||
//
|
||||
// Two things shape this form:
|
||||
//
|
||||
// • Every control is a closed set. The presets, the font shortlist and the
|
||||
// shadow depths all come from GET /settings/theme/options, which is derived
|
||||
// from the same server config the save is validated against — so the form
|
||||
// can never offer a value the server would reject. Nothing here is free
|
||||
// text except the color inputs, which are <input type="color"> and so are
|
||||
// hex by construction.
|
||||
// • Saving means writing a settings row; resetting means DELETING it. Absence
|
||||
// of the row is what selects the shipped default, so "reset" cannot write a
|
||||
// copy of the defaults — see §2.
|
||||
|
||||
// Human labels for the eight editable colors and four radii. The field names
|
||||
// and the CSS variables they drive both come from the server
|
||||
// (colorFields / radiusFields); this only decorates them, and a field with no
|
||||
// label here still renders under its raw name rather than vanishing.
|
||||
const COLOR_LABELS = {
|
||||
bg: 'Background',
|
||||
bgDeep: 'Background (deep)',
|
||||
panelA: 'Panel (top)',
|
||||
panelB: 'Panel (bottom)',
|
||||
accent: 'Accent',
|
||||
accentBright: 'Accent (bright)',
|
||||
ink: 'Ink / headings',
|
||||
text: 'Body text',
|
||||
}
|
||||
const RADIUS_LABELS = {
|
||||
radiusPill: 'Pills & buttons',
|
||||
radiusPanel: 'Flat panels',
|
||||
radiusCard: 'Cards & panels',
|
||||
radiusInput: 'Inputs & notes',
|
||||
}
|
||||
const FONT_LABELS = {
|
||||
serif: 'Body serif',
|
||||
display: 'Display / headings',
|
||||
sans: 'Interface sans',
|
||||
}
|
||||
|
||||
// Strip empty groups so a theme the admin cleared back out is stored as a bare
|
||||
// preset rather than as `{colors:{}, fonts:{}, structure:{}}`. Never null a
|
||||
// field out to "clear" it — remove it (§6.1).
|
||||
function compactCustom(custom) {
|
||||
const out = {}
|
||||
for (const [group, fields] of Object.entries(custom)) {
|
||||
const kept = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== '' && v != null))
|
||||
if (Object.keys(kept).length) out[group] = kept
|
||||
}
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
export default function AppearanceAdmin() {
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const [options, setOptions] = useState(null)
|
||||
const [preset, setPreset] = useState('runic-gateway')
|
||||
const [custom, setCustom] = useState({ colors: {}, fonts: {}, structure: {} })
|
||||
// Whether a theme_visual row exists at all. Drives the "reset" button and the
|
||||
// "this instance is using the shipped theme" note — an admin needs to be able
|
||||
// to tell "never themed" from "themed to look like the default".
|
||||
const [stored, setStored] = useState(false)
|
||||
// The brand-asset overrides, read in the same settings fetch and then owned by
|
||||
// the panel below (its uploads save on their own, so it does not share this
|
||||
// screen's Save button).
|
||||
const [assets, setAssets] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
Promise.all([api.themeOptions(), api.admin.getSettings()])
|
||||
.then(([opts, all]) => {
|
||||
if (!active) return
|
||||
setOptions(opts)
|
||||
// The stored values are JSON strings (settings.value is TEXT), and a
|
||||
// malformed one reads as absent exactly as the server treats it — the
|
||||
// form then shows the shipped default rather than an error.
|
||||
const parsed = parseJsonSetting(all.theme_visual)
|
||||
setStored(Boolean(all.theme_visual))
|
||||
setAssets(parseJsonSetting(all.brand_assets) || {})
|
||||
if (parsed) {
|
||||
setPreset(parsed.preset || 'runic-gateway')
|
||||
setCustom({
|
||||
colors: parsed.custom?.colors || {},
|
||||
fonts: parsed.custom?.fonts || {},
|
||||
structure: parsed.custom?.structure || {},
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => active && setError('Could not load the appearance settings.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// What an unset field currently resolves to: the selected preset's palette,
|
||||
// or the shipped theme when the preset is Custom (which has no base). Lets a
|
||||
// color picker open on the value the admin is actually looking at.
|
||||
const baseTokens = useMemo(() => {
|
||||
if (!options) return {}
|
||||
return options.presets.find((p) => p.id === preset)?.tokens || options.shippedTokens
|
||||
}, [options, preset])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !options) return <ErrorState message={error} />
|
||||
|
||||
const setField = (group, field) => (value) => {
|
||||
setCustom((c) => ({ ...c, [group]: { ...c[group], [field]: value } }))
|
||||
setSaved(false)
|
||||
}
|
||||
const clearField = (group, field) => () => {
|
||||
setCustom((c) => {
|
||||
const next = { ...c[group] }
|
||||
delete next[field]
|
||||
return { ...c, [group]: next }
|
||||
})
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.updateSettings({ theme_visual: { preset, custom: compactCustom(custom) } })
|
||||
setStored(true)
|
||||
setSaved(true)
|
||||
// Repull the public settings so the surrounding admin UI re-themes itself
|
||||
// immediately — the admin sees the change they just made.
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the theme.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAll() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.resetSetting('theme_visual')
|
||||
setPreset('runic-gateway')
|
||||
setCustom({ colors: {}, fonts: {}, structure: {} })
|
||||
setStored(false)
|
||||
setSaved(false)
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not reset the theme.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||
Colors, fonts and corner radius for the public site, this admin panel and the player portal.
|
||||
{' '}
|
||||
{stored ? (
|
||||
<>This instance has a saved theme. <strong style={{ color: 'var(--muted)' }}>Reset to default</strong> deletes it and returns to the shipped look.</>
|
||||
) : (
|
||||
<>This instance has never been themed, so it uses the shipped look and its <code>BRAND_*</code> accent.</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* ── Preset ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Preset</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
|
||||
{options.presets.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPreset(p.id)
|
||||
setSaved(false)
|
||||
}}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: `1px solid ${preset === p.id ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: preset === p.id ? 'var(--blue)' : 'transparent',
|
||||
color: preset === p.id ? 'var(--ink)' : 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
aria-pressed={preset === p.id}
|
||||
>
|
||||
{p.tokens ? (
|
||||
<span style={{ display: 'flex', borderRadius: 4, overflow: 'hidden', border: '1px solid var(--line)' }}>
|
||||
{['--bg', '--panel-a', '--accent', '--ink'].map((t) => (
|
||||
<span key={t} style={{ width: 11, height: 18, background: p.tokens[t] }} />
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ width: 44, height: 18, borderRadius: 4, border: '1px dashed var(--line)' }} />
|
||||
)}
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
{preset === 'custom'
|
||||
? 'Custom starts from the shipped theme — only the fields you set below change.'
|
||||
: 'A preset sets the whole palette. Anything you set below overrides it, field by field.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Colors ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Colors</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
|
||||
{options.colorFields.map(({ name, token }) => {
|
||||
const set = custom.colors[name] !== undefined
|
||||
return (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{/* <input type="color"> has no empty state, so an unset field
|
||||
shows what it currently resolves to rather than black. */}
|
||||
<input
|
||||
type="color"
|
||||
value={custom.colors[name] || baseTokens[token] || '#000000'}
|
||||
onChange={(e) => setField('colors', name)(e.target.value)}
|
||||
aria-label={COLOR_LABELS[name] || name}
|
||||
style={{ width: 34, height: 30, padding: 0, border: '1px solid var(--line)', borderRadius: 6, background: 'transparent', cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="sans" style={{ flex: 1, fontSize: '0.82rem', color: set ? 'var(--ink)' : 'var(--dim)' }}>
|
||||
{COLOR_LABELS[name] || name}
|
||||
</span>
|
||||
{set && (
|
||||
<button type="button" onClick={clearField('colors', name)} className="sans" title="Follow the preset again" style={linkBtn}>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
A color you have not set follows the preset. “Live” and “maintenance” status colors are never themed — green has to keep meaning live.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Fonts ──────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Fonts</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 8 }}>
|
||||
{Object.keys(options.fonts).map((role) => (
|
||||
<label key={role} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
{FONT_LABELS[role] || role}
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={custom.fonts[role] || ''}
|
||||
onChange={(e) => (e.target.value ? setField('fonts', role)(e.target.value) : clearField('fonts', role)())}
|
||||
>
|
||||
<option value="">Follow the preset</option>
|
||||
{options.fonts[role].map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Structure ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Corners & depth</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
|
||||
{options.radiusFields.map(({ name, token }) => (
|
||||
<label key={name} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
{RADIUS_LABELS[name] || name}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max={options.radiusMaxPx}
|
||||
placeholder={(baseTokens[token] || '').replace('px', '')}
|
||||
value={(custom.structure[name] || '').replace('px', '')}
|
||||
onChange={(e) =>
|
||||
e.target.value === ''
|
||||
? clearField('structure', name)()
|
||||
: setField('structure', name)(`${Math.min(Math.max(parseInt(e.target.value, 10) || 0, 0), options.radiusMaxPx)}px`)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label style={{ display: 'block', marginTop: 12 }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
Card shadow
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={custom.structure.shadowDepth || ''}
|
||||
onChange={(e) => (e.target.value ? setField('structure', 'shadowDepth')(e.target.value) : clearField('structure', 'shadowDepth')())}
|
||||
>
|
||||
<option value="">Follow the preset</option>
|
||||
{options.shadows.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save theme'}
|
||||
</button>
|
||||
<button onClick={resetAll} disabled={busy || !stored} className="pill" title={stored ? 'Delete the saved theme' : 'Nothing to reset'}>
|
||||
Reset to default
|
||||
</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>
|
||||
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
||||
The accent reaches the mobile app and the Discord bot too — both theme themselves from this
|
||||
site’s public branding.
|
||||
</p>
|
||||
|
||||
{/* ── Brand assets ───────────────────────────────────────── */}
|
||||
<BrandAssetsPanel initial={assets || {}} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const linkBtn = {
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}
|
||||
@@ -44,6 +44,14 @@ function CallbackHint({ id }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Live = enabled and healthy; Incomplete = enabled but missing/invalid config;
|
||||
// Disabled otherwise.
|
||||
function ProviderStatus({ provider: p }) {
|
||||
if (p.enabled && p.health.valid) return <span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
|
||||
if (p.enabled) return <span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
|
||||
return <span className="sans dim">Disabled</span>
|
||||
}
|
||||
|
||||
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)' }}>
|
||||
@@ -292,13 +300,7 @@ function CustomProviders({ items, onChanged }) {
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{p.name}</td>
|
||||
<td className="adm-td dim">{p.kind}</td>
|
||||
<td className="adm-td">
|
||||
{p.enabled && p.health.valid ? (
|
||||
<span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
|
||||
) : p.enabled ? (
|
||||
<span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
|
||||
) : (
|
||||
<span className="sans dim">Disabled</span>
|
||||
)}
|
||||
<ProviderStatus provider={p} />
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<span className="link-accent" onClick={() => setEditing(p)}>Edit</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user