Compare commits
91 Commits
feature/mo
...
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 |
27
.env.example
27
.env.example
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -56,6 +56,12 @@ jobs:
|
||||
# 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
|
||||
@@ -69,6 +75,15 @@ jobs:
|
||||
# of a reviewer instead of letting it pass silently.
|
||||
run: npm run routes:manifest --prefix server -- --check
|
||||
|
||||
- name: Check the engagement trigger manifest is current
|
||||
# ENGAGEMENT.md 4.3 property 4 - the same mechanism as the route manifest
|
||||
# above, for the event contract instead of the URL surface. A trigger
|
||||
# declaration is what a stored template interpolates and what a stored
|
||||
# rule is written against, so renaming a variable or widening a ceiling
|
||||
# breaks them silently, at send time, in mail someone already received.
|
||||
# Regenerating and diffing makes that change something a reviewer reads.
|
||||
run: npm run engagement:manifest --prefix server -- --check
|
||||
|
||||
client-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -86,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
|
||||
@@ -99,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
|
||||
|
||||
17
README.md
17
README.md
@@ -151,7 +151,7 @@ flowchart TB
|
||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||
| Database | MariaDB 11 (own container) |
|
||||
| Frontend | React 18, Vite 5, React Router 6 |
|
||||
| Email | Nodemailer 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, …) |
|
||||
|
||||
@@ -216,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):
|
||||
@@ -579,7 +584,7 @@ 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) |
|
||||
@@ -672,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"],
|
||||
|
||||
@@ -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() {
|
||||
@@ -54,6 +89,11 @@ async function stop() {
|
||||
// failure here leaves the client connected but flags an error status.
|
||||
async function onReady() {
|
||||
try {
|
||||
// Pull BEFORE the single PUT, so the app's commands are in the very first
|
||||
// registration rather than appearing a beat later. The pull never throws —
|
||||
// an unreachable app costs the module commands and nothing else, and the
|
||||
// bot's own set registers exactly as it always did.
|
||||
await dynamicCommands.pull()
|
||||
await registerCommands(client.application.id, guildId)
|
||||
await scheduler.start(client)
|
||||
tempRoleSweeper.start(client)
|
||||
@@ -70,14 +110,19 @@ async function onReady() {
|
||||
}
|
||||
}
|
||||
|
||||
// Route an interaction: role-menu handler first, then chat-input slash commands.
|
||||
// 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) return
|
||||
if (!command && !dynamicCommands.has(interaction.commandName)) return
|
||||
try {
|
||||
await command.execute(interaction)
|
||||
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 }
|
||||
@@ -146,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 }
|
||||
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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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 }
|
||||
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)
|
||||
})
|
||||
@@ -42,19 +42,31 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
|
||||
import EngagementRules from './routes/admin/views/EngagementRules.jsx'
|
||||
import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
|
||||
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
|
||||
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
|
||||
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
||||
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
|
||||
import 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, { 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() {
|
||||
@@ -162,6 +174,7 @@ export default function App() {
|
||||
<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 />} />
|
||||
@@ -174,7 +187,38 @@ export default function App() {
|
||||
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
|
||||
@@ -196,7 +240,14 @@ export default function App() {
|
||||
<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>
|
||||
@@ -211,6 +262,14 @@ export default function App() {
|
||||
<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
|
||||
|
||||
@@ -105,6 +105,36 @@ export const api = {
|
||||
revokeTrustedDevice: (id) =>
|
||||
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
|
||||
// Self-service account security, role-agnostic under /auth/me/account. This is
|
||||
// the ONLY surface for it: the /admin/account/* and /player/account/* copies
|
||||
// were deleted (both were strictly smaller — neither carried recovery codes),
|
||||
// which is why recovery codes below already lived here while the rest did not.
|
||||
// The change endpoints re-issue the session cookie server-side, so the caller
|
||||
// stays signed in.
|
||||
myAccount: () => req('/auth/me/account'),
|
||||
changeUsername: (username) =>
|
||||
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
|
||||
changePassword: (newPassword, currentPassword) =>
|
||||
req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
||||
// Email address (engagement Phase 1b). changeEmail STAGES the address — the
|
||||
// account keeps its current one until the emailed link is opened — so the UI
|
||||
// must show `email_pending` as pending, never as the address in force.
|
||||
changeEmail: (email, currentPassword) =>
|
||||
req('/auth/me/account/email', { method: 'PATCH', body: { email, currentPassword } }),
|
||||
resendEmailVerification: () => req('/auth/me/account/email/resend', { method: 'POST' }),
|
||||
cancelEmailChange: () => req('/auth/me/account/email/pending', { method: 'DELETE' }),
|
||||
// The confirm half is public and token-gated — it is reached from a mailbox,
|
||||
// often with no session, so it deliberately sits outside /auth/me.
|
||||
lookupEmailVerification: (token) => req(`/auth/email/verify/${encodeURIComponent(token)}`),
|
||||
confirmEmailVerification: (token) =>
|
||||
req(`/auth/email/verify/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
|
||||
myIdentities: () => req('/auth/me/account/identities'),
|
||||
unlinkIdentity: (provider) =>
|
||||
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
|
||||
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
||||
// returned ONCE (password step-up for accounts that have a password).
|
||||
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
||||
@@ -133,6 +163,99 @@ export const api = {
|
||||
return req(`/public/wiki${withQs(s)}`)
|
||||
},
|
||||
wikiCategories: () => req('/public/wiki/categories'),
|
||||
|
||||
// ----- Teams (TEAMS.md §2.11, §4.3) -----
|
||||
//
|
||||
// Only the two calls CORE's own client makes. Core renders no Team pages — the
|
||||
// vocabulary belongs to whichever module owns the surface — so the index, the
|
||||
// roster and the player list are not here; a module that renders those calls
|
||||
// the same public API from its own client.
|
||||
//
|
||||
// The lookup exists because a module names a Team in its own terms and core
|
||||
// keys the feed by slug. Resolving that is core's job precisely so a module
|
||||
// never has to hold core's identifiers.
|
||||
teamByExternalId: (moduleId, externalId) =>
|
||||
req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
|
||||
teamActivity: (slug, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.limit != null) qs.set('limit', String(opts.limit))
|
||||
if (opts.offset != null) qs.set('offset', String(opts.offset))
|
||||
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
|
||||
},
|
||||
// The Team FORUM, under /player because a participant may be a plain player and
|
||||
// a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
|
||||
// core's: only core resolves whether this viewer is inside the Team, and the
|
||||
// member/guest split is a security boundary. The module renders the PLACE.
|
||||
teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
|
||||
teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
|
||||
teamForumPost: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
|
||||
teamForumModerate: (slug, id, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
|
||||
// Phase 5 ("5b"). A reply, an edit and post-level moderation are separate
|
||||
// routes from their thread-level cousins rather than the same route with a
|
||||
// target kind, because they answer to different rules: a reply is refused by a
|
||||
// lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all.
|
||||
teamForumReply: (slug, threadId, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }),
|
||||
teamForumEditPost: (slug, postId, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }),
|
||||
teamForumModeratePost: (slug, postId, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }),
|
||||
// The report goes to SITE STAFF, never to the Team's leaders — the whole point
|
||||
// of it is a path that routes around a Team's own leadership (TEAMS.md §5.6).
|
||||
// There is no leader-facing counterpart to this call and there should not be.
|
||||
teamForumReport: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }),
|
||||
teamForumUpload: (slug, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
|
||||
teamGrantAdd: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
|
||||
teamGrantRevoke: (slug, userId) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- notifications (TEAMS.md Part 6) -----
|
||||
//
|
||||
// Under /auth/me rather than /player: these are role-agnostic self-service, the
|
||||
// same rule that put the forum under /player rather than behind a staff gate.
|
||||
// The streams catalog and the per-stream subscriptions were built for the app
|
||||
// and had no web consumer at all until phase 6 gave them one.
|
||||
notificationStreams: () => req('/auth/me/notifications/streams'),
|
||||
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
|
||||
// `streams` is always sent, empty array included — the endpoint requires the
|
||||
// field, so clearing the last subscription must not become an absent key.
|
||||
setNotificationSubscriptions: (streams) =>
|
||||
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
|
||||
// Per-channel preferences (ENGAGEMENT.md Phase 3). A SPARSE update: only the
|
||||
// (id, channel) pairs sent are written, so a screen managing one channel need
|
||||
// not know what the others hold. Shipped with no surface at all until Phase 7.
|
||||
notificationChannelPrefs: () => req('/auth/me/notifications/channels'),
|
||||
setNotificationChannelPrefs: (prefs) =>
|
||||
req('/auth/me/notifications/channels', { method: 'PUT', body: { prefs } }),
|
||||
// The in-app inbox (ENGAGEMENT.md Phase 7). `before` is a keyset cursor — the
|
||||
// id of the last item on the previous page — not an offset: the list gains
|
||||
// rows at the top while it is being read.
|
||||
notifications: ({ limit, before, unread } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (before) qs.set('before', String(before))
|
||||
if (unread) qs.set('unread', 'true')
|
||||
return req(`/auth/me/notifications${withQs(qs.toString())}`)
|
||||
},
|
||||
notificationsUnreadCount: () => req('/auth/me/notifications/unread-count'),
|
||||
markNotificationRead: (id) => req(`/auth/me/notifications/${id}/read`, { method: 'POST' }),
|
||||
markAllNotificationsRead: () => req('/auth/me/notifications/read-all', { method: 'POST' }),
|
||||
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
|
||||
setTeamNotificationPrefs: (teams) =>
|
||||
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
|
||||
// Unauthenticated, and the one write in the public tier: the caller is reading
|
||||
// their mail, not signed in. Always resolves 200 whatever the token was.
|
||||
unsubscribeTeam: (token) =>
|
||||
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
@@ -220,6 +343,12 @@ 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) =>
|
||||
@@ -247,8 +376,138 @@ export const api = {
|
||||
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)
|
||||
@@ -308,16 +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' }),
|
||||
|
||||
// ----- auth providers / SSO config (admin only) -----
|
||||
listAuthProviders: () => req('/admin/auth/providers'),
|
||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||
@@ -328,29 +577,19 @@ export const api = {
|
||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||
|
||||
// ----- 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' }),
|
||||
|
||||
// ----- moderation appeals (self-service) -----
|
||||
getMyAppeals: () => req('/player/appeals'),
|
||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||
|
||||
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,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>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
@@ -107,6 +108,10 @@ export default function SiteHeader() {
|
||||
</NavLink>
|
||||
),
|
||||
)}
|
||||
{/* Renders nothing when signed out, so the header keeps its shape for
|
||||
a visitor. It is here rather than only in the portal because an
|
||||
inbox item is worth seeing from the page you are already on. */}
|
||||
{!loading && <NotificationBell />}
|
||||
{!loading && (
|
||||
<NavLink
|
||||
to={account.to}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
},
|
||||
})
|
||||
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`
|
||||
}
|
||||
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`
|
||||
}
|
||||
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.`
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { declareSlot } from './modules/registry.js'
|
||||
import { declareSlot, applyCoreFills, offerCoreFill } from './modules/registry.js'
|
||||
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
||||
import TeamForumPanel from './modules/TeamForumPanel.jsx'
|
||||
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -18,8 +21,6 @@ publishSharedDependencies()
|
||||
// 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.
|
||||
// There is nothing for core to register now — no core nav row carries a
|
||||
// `feature` — and the filter is a correct no-op until a module supplies one.
|
||||
|
||||
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
|
||||
//
|
||||
@@ -56,6 +57,49 @@ declareSlot('player.invite.accepted')
|
||||
// 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.
|
||||
//
|
||||
@@ -82,6 +126,10 @@ declareSlot('player.invite.accepted')
|
||||
// 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>
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -135,6 +135,120 @@ export function declareSlot(name) {
|
||||
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.
|
||||
*
|
||||
@@ -207,6 +321,7 @@ export function _reset() {
|
||||
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
|
||||
@@ -224,6 +339,8 @@ export const registry = {
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
registerExtension,
|
||||
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
|
||||
declareModuleSlot,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
|
||||
@@ -34,13 +34,15 @@ import { MODULE_API_VERSION } from './version.js'
|
||||
import PublicLayout from '../components/PublicLayout.jsx'
|
||||
import PageHeader from '../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
|
||||
import Slot from './Slot.jsx'
|
||||
import { useAsync } from '../lib/useAsync.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { request, ApiError, BASE } from '../api/client.js'
|
||||
|
||||
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
|
||||
// seven are what the smallest UO page already needs beyond React and the router:
|
||||
// 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
|
||||
@@ -50,11 +52,12 @@ import { request, ApiError, BASE } from '../api/client.js'
|
||||
// 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` appears in §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 is amended rather than the code padded, and adding it
|
||||
// later costs a minor bump, which is exactly the case the versioning is for.
|
||||
// `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,
|
||||
@@ -64,6 +67,13 @@ const ui = {
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
// The ninth member, for the INVERTED slot direction (TEAMS.md Part 3). A
|
||||
// module that declares a slot on its own page needs the same component core
|
||||
// renders its own with — the error boundary in particular, since the thing
|
||||
// being contained here is CORE's content failing inside the MODULE's page.
|
||||
// Shared rather than reimplemented for the reason the whole kit exists: two
|
||||
// boundaries with different behaviour would be two bugs.
|
||||
Slot,
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
|
||||
|
||||
@@ -11,6 +11,38 @@
|
||||
// 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
|
||||
@@ -26,4 +58,4 @@
|
||||
// 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.3.0'
|
||||
export const MODULE_API_VERSION = '1.8.0'
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -43,9 +44,15 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
|
||||
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
||||
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
|
||||
const 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`
|
||||
@@ -76,6 +83,36 @@ export const NAV = [
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
// Member-raised reports (TEAMS.md §5.6). Here rather than under Teams
|
||||
// because a staffer working a queue should have one place to work — and
|
||||
// because the queue is deliberately generic, so the next thing that can
|
||||
// be reported arrives as a row rather than as another nav entry.
|
||||
{ to: '/admin/moderation/reports', label: 'Reports', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
// Moderation rather than System: the screen's daily job is the
|
||||
// reserved-name review queue, which is moderator work. The three actions
|
||||
// that publish a game-written name are gated to admins server-side, so a
|
||||
// moderator reaching this screen is correct — what they do here is file a
|
||||
// request (TEAMS.md §2.9).
|
||||
{ to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
|
||||
// Settings. Settings is already one long page of sections, and these six
|
||||
// screens are two editors, a catalog and two paged tables, none of which is
|
||||
// a settings section. Email Delivery stays under Settings: configuring a
|
||||
// transport is not the same job as deciding who gets mail.
|
||||
title: 'Engagement',
|
||||
items: [
|
||||
{ to: '/admin/engagement/rules', label: 'Rules', icon: IconMail, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/audiences', label: 'Audiences', icon: IconList, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
|
||||
// Beside the Send Log rather than inside it (Phase 9): the log answers
|
||||
// "did that message go out", and this answers "why is this person not
|
||||
// getting any" - and it is the only screen that can lift a suppression.
|
||||
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -98,6 +135,11 @@ export const NAV = [
|
||||
},
|
||||
{
|
||||
items: [
|
||||
// No `end`: `allowedPathsFor` turns an `end` row into an EXACT match, so
|
||||
// marking this one exact would leave `/admin/notifications/settings`
|
||||
// outside the allowlist and bounce a staff member off their own
|
||||
// preferences screen. The row covering its sub-routes is the point.
|
||||
{ to: '/admin/notifications', label: 'Notifications', icon: IconBell },
|
||||
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
||||
],
|
||||
},
|
||||
@@ -134,6 +176,8 @@ const TITLES = {
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/moderation/appeals': 'Appeals',
|
||||
'/admin/moderation/reports': 'Reports',
|
||||
'/admin/teams': 'Teams',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/appearance': 'Appearance',
|
||||
'/admin/navigation': 'Navigation',
|
||||
@@ -144,6 +188,14 @@ const TITLES = {
|
||||
'/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
|
||||
@@ -163,6 +215,7 @@ function moduleTitle(baseNav, pathname) {
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -404,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>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -25,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)
|
||||
@@ -44,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.')
|
||||
@@ -134,7 +135,7 @@ export default function AccountAdmin() {
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setAccount(await api.admin.getAccount())
|
||||
setAccount(await api.myAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
@@ -154,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.')
|
||||
@@ -168,7 +169,7 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.admin.totpEnable(code.trim())
|
||||
const res = await api.totpEnable(code.trim())
|
||||
setSetup(null)
|
||||
setCode('')
|
||||
setNewCodes(res?.recoveryCodes || null)
|
||||
@@ -186,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()
|
||||
@@ -322,6 +323,10 @@ export default function AccountAdmin() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The self-service address, from the same component the player portal
|
||||
renders — /auth/me/account is one surface for every role. */}
|
||||
{account && <EmailAddressPanel account={account} reload={load} />}
|
||||
|
||||
<LinkedAccounts />
|
||||
</section>
|
||||
)
|
||||
|
||||
310
client/src/routes/admin/views/ContentReports.jsx
Normal file
310
client/src/routes/admin/views/ContentReports.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import Modal from '../../../components/Modal.jsx'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago, dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// The member-raised content-report queue (TEAMS.md §5.6).
|
||||
//
|
||||
// **This is the only view of this queue, and that is the design.** The gap §5.6
|
||||
// exists to close has a specific shape: leaders moderate their own Team's forum,
|
||||
// and a Team's leaders are exactly the people who will not report their own Team.
|
||||
// A leader-visible queue would route a complaint about a leader back to that
|
||||
// leader. Org lead, 2026-08-18: reports are **site administration only**. If a
|
||||
// leader-facing view is ever wanted it is a design decision, not a component.
|
||||
//
|
||||
// It sits beside Appeals rather than under Teams because a staffer working a
|
||||
// queue should have one place to work — and because `target_type` is deliberately
|
||||
// open-ended, so the next consumer (a wiki page, a news comment) arrives as a new
|
||||
// row here rather than as a new screen.
|
||||
//
|
||||
// **Handling a report is bookkeeping about the REPORT, not moderation of the
|
||||
// content.** Acting on the content itself is the ordinary forum moderation
|
||||
// control, or a site-wide sanction against the account. Keeping those separate is
|
||||
// what stops "report" from becoming a way for any member to hide anything, so
|
||||
// this screen deliberately offers no hide/delete button of its own.
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: 'open_work', label: 'Open work', param: undefined },
|
||||
{ key: 'open', label: 'Open', param: 'open' },
|
||||
{ key: 'reviewing', label: 'Reviewing', param: 'reviewing' },
|
||||
{ key: 'actioned', label: 'Actioned', param: 'actioned' },
|
||||
{ key: 'dismissed', label: 'Dismissed', param: 'dismissed' },
|
||||
{ key: 'all', label: 'All', param: 'all' },
|
||||
]
|
||||
|
||||
const STATUS_STYLE = {
|
||||
open: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
|
||||
reviewing: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
|
||||
actioned: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
|
||||
dismissed: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
|
||||
}
|
||||
const STATUS_LABEL = {
|
||||
open: 'Open', reviewing: 'Reviewing', actioned: 'Actioned', dismissed: 'Dismissed',
|
||||
}
|
||||
|
||||
const REASON_LABEL = {
|
||||
spam: 'Spam',
|
||||
abuse: 'Abuse',
|
||||
sexual: 'Sexual',
|
||||
illegal: 'Illegal',
|
||||
impersonation: 'Impersonation',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
const bytes = (n) => {
|
||||
if (!n && n !== 0) return ''
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* What was reported, rendered from the row the queue already resolved.
|
||||
*
|
||||
* Nothing here fetches: §5.6's fourth rule is that a staffer sees uploader, size
|
||||
* and sniffed type without hunting, and the server attaches all of it in three
|
||||
* batched reads. A `null` target is a target that has since been hard-deleted,
|
||||
* and the row still shows — "somebody reported this and by the time we looked it
|
||||
* was gone" is a fact worth seeing, and dropping it would hide the pattern of a
|
||||
* member deleting their own content the moment it is reported.
|
||||
*/
|
||||
function TargetCell({ report }) {
|
||||
const t = report.target
|
||||
if (!t) {
|
||||
return (
|
||||
<span style={{ color: 'var(--muted)' }}>
|
||||
{report.targetType.replace('team_forum_', '')} #{report.targetId} — no longer exists
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (t.kind === 'upload') {
|
||||
return (
|
||||
<span>
|
||||
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
|
||||
{t.deleted && ' · removed'}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (t.kind === 'thread') {
|
||||
return (
|
||||
<span>
|
||||
<strong>{t.title}</strong>
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.type} by {t.author || 'unknown'}
|
||||
{t.status !== 'visible' && ` · ${t.status}`}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{t.excerpt || <em className="dim">(no text)</em>}
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
|
||||
{t.author || 'unknown'} in “{t.threadTitle}”
|
||||
{t.status !== 'visible' && ` · ${t.status}`}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ContentReports() {
|
||||
const [tab, setTab] = useState('open_work')
|
||||
const [tick, setTick] = useState(0)
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
const [handling, setHandling] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
|
||||
const { loading, error, data } = useAsync(
|
||||
() => api.admin.contentReports({ status: activeTab.param }),
|
||||
[tab, tick],
|
||||
)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load reports." />
|
||||
|
||||
const rows = data?.reports || []
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
|
||||
Reports raised by members about Team forum content. They come to site staff and are not visible
|
||||
to a Team’s own leaders — a leader moderates their own forum, so a report about a leader
|
||||
has to reach someone above them. Handling a report records a decision about the report; hiding
|
||||
or removing the content itself is done from the forum, or as a sanction against the account.
|
||||
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className="pill"
|
||||
style={tab === t.key ? activePill : undefined}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<p
|
||||
className="sans"
|
||||
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Reported content</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th">Reporter</th>
|
||||
<th className="adm-th">Age</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={7} style={muted}>
|
||||
No reports match this filter.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 340 }}>
|
||||
<TargetCell report={r} />
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
|
||||
<td className="adm-td dim">{r.reporter}</td>
|
||||
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
|
||||
<td className="adm-td">
|
||||
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
|
||||
{r.handledBy && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
|
||||
{r.handledBy}
|
||||
{r.handledNote ? ` — ${r.handledNote}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
onClick={() => setHandling(r)}
|
||||
className="btn btn-primary btn-sq"
|
||||
style={{ padding: '5px 12px', fontSize: '0.82rem' }}
|
||||
>
|
||||
Handle
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{handling && (
|
||||
<HandleModal
|
||||
report={handling}
|
||||
onCancel={() => setHandling(null)}
|
||||
onDone={() => {
|
||||
setHandling(null)
|
||||
setNotice({ text: 'Report updated.', tone: 'ok' })
|
||||
reload()
|
||||
}}
|
||||
onError={(message) => setNotice({ text: message, tone: 'error' })}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a decision about a report.
|
||||
*
|
||||
* The note is optional and worth writing: every transition is audited, dismissals
|
||||
* included, and the note is what the next staffer to see a repeat report about the
|
||||
* same content reads to find out why the last one was closed.
|
||||
*/
|
||||
function HandleModal({ report, onCancel, onDone, onError }) {
|
||||
const [status, setStatus] = useState(report.status === 'open' ? 'reviewing' : 'actioned')
|
||||
const [note, setNote] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.admin.handleContentReport(report.id, { status, note: note || undefined })
|
||||
onDone()
|
||||
} catch (err) {
|
||||
onError(err.message || 'Could not update that report.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Report #${report.id}`}
|
||||
onClose={onCancel}
|
||||
footer={(
|
||||
<>
|
||||
<button className="pill" onClick={onCancel}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
|
||||
This records a decision about the report. It does not hide, delete or restore the content —
|
||||
do that from the forum itself, or against the account.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{['reviewing', 'actioned', 'dismissed', 'open'].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setStatus(value)}
|
||||
className="pill"
|
||||
style={status === value ? activePill : undefined}
|
||||
>
|
||||
{STATUS_LABEL[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span className="field-label">Note (optional)</span>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="Why this was actioned or dismissed — the next staffer to see a repeat report reads this."
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
|
||||
const muted = { color: 'var(--muted)' }
|
||||
@@ -66,6 +66,34 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Operator warnings: things that are quietly not working and would
|
||||
otherwise be discovered by someone not receiving an email. The list is
|
||||
normally empty, which is why it sits above the fold rather than in a
|
||||
panel — see ENGAGEMENT.md §1.2a (G22). */}
|
||||
{(dash.warnings || []).map((w) => (
|
||||
<div
|
||||
key={w.code}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.86rem',
|
||||
lineHeight: 1.5,
|
||||
borderRadius: 10,
|
||||
padding: '12px 16px',
|
||||
marginBottom: 18,
|
||||
border: '1px solid #7a6440',
|
||||
background: 'rgba(224,176,112,0.08)',
|
||||
color: '#e0b070',
|
||||
}}
|
||||
>
|
||||
{w.message}
|
||||
{w.href && (
|
||||
<>
|
||||
{' '}
|
||||
<a href={w.href} style={{ color: '#e0b070', textDecoration: 'underline' }}>Open settings</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -2,11 +2,20 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
|
||||
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
|
||||
// captures a refresh token server-side — the token is write-only over the API
|
||||
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
|
||||
// requires the Google provider to be configured on the Authentication page first.
|
||||
// Email delivery panel, rendered as a section on the Settings page. Sending goes
|
||||
// through a registered mail transport (SMTP today) whose credentials the operator
|
||||
// types here; they are stored encrypted server-side and are write-only over the
|
||||
// API — a secret field comes back as "set", never as its value.
|
||||
//
|
||||
// **The form is not written here.** The server ships each transport's declared
|
||||
// `credentialFields` with the config, and this renders them. That is the whole
|
||||
// point of the declaration (ENGAGEMENT.md §3.1): adding a transport must not mean
|
||||
// editing this file. So there is no `host`, `port` or `password` anywhere below —
|
||||
// only field kinds.
|
||||
//
|
||||
// The "Connect Gmail" button, its redirect banner and its six error strings went
|
||||
// with the OAuth2 flow (§1.2a). Gmail is still reachable, as an ordinary SMTP
|
||||
// relay with an app password — which the operator types in like any other host.
|
||||
|
||||
const STATUS_COLOR = {
|
||||
connected: '#7fd0a4',
|
||||
@@ -14,17 +23,6 @@ const STATUS_COLOR = {
|
||||
unconfigured: 'var(--muted)',
|
||||
}
|
||||
|
||||
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
|
||||
const ERROR_TEXT = {
|
||||
denied: 'Google sign-in was cancelled or denied.',
|
||||
bad_state: 'The connect session expired. Please try again.',
|
||||
no_client: 'The Google OAuth client is not configured.',
|
||||
no_refresh_token:
|
||||
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
|
||||
no_email: 'Could not read the Gmail address from Google.',
|
||||
error: 'Could not connect the Gmail account. Please try again.',
|
||||
}
|
||||
|
||||
function StatusPanel({ config }) {
|
||||
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||
return (
|
||||
@@ -52,60 +50,100 @@ function StatusPanel({ config }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One declared credential field. A `secret` already held renders empty with a
|
||||
// "leave blank to keep" hint, matching the server's patch semantics: an empty
|
||||
// secret is omitted from the save, not written as a blank.
|
||||
function CredentialField({ field, value, isSet, onChange }) {
|
||||
const hint = [field.help, field.kind === 'secret' && isSet ? 'Currently set — leave blank to keep it.' : null]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
if (field.kind === 'boolean') {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} style={{ marginTop: 3 }} />
|
||||
<span>
|
||||
{field.label}
|
||||
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem' }}>{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">
|
||||
{field.label}
|
||||
{field.required ? '' : ' (optional)'}
|
||||
</span>
|
||||
<input
|
||||
type={field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="input"
|
||||
autoComplete={field.kind === 'secret' ? 'new-password' : 'off'}
|
||||
placeholder={field.placeholder || ''}
|
||||
/>
|
||||
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>{hint}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EmailDelivery() {
|
||||
const { siteTitle } = useSite()
|
||||
const [config, setConfig] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [transport, setTransport] = useState('smtp')
|
||||
const [senderEmail, setSenderEmail] = useState('')
|
||||
const [senderName, setSenderName] = useState('')
|
||||
const [replyTo, setReplyTo] = useState('')
|
||||
const [credential, setCredential] = useState({})
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
|
||||
|
||||
// Seed the credential inputs from the non-secret values the server returned,
|
||||
// falling back to each field's declared default. Secrets are never seeded —
|
||||
// the server does not send them and an empty box means "keep what you have".
|
||||
const seedCredential = useCallback((c, transportId) => {
|
||||
const def = (c.transports || []).find((t) => t.id === transportId)
|
||||
const next = {}
|
||||
for (const f of def?.credentialFields || []) {
|
||||
if (f.kind === 'secret') continue
|
||||
next[f.key] = c.credential?.[f.key] ?? (f.default === null ? '' : f.default)
|
||||
}
|
||||
return next
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async (seedForm = false) => {
|
||||
try {
|
||||
const c = await api.admin.getEmailConfig()
|
||||
setConfig(c)
|
||||
if (seedForm) {
|
||||
setTransport(c.transport || 'smtp')
|
||||
setSenderEmail(c.senderEmail || '')
|
||||
setSenderName(c.senderName || '')
|
||||
setReplyTo(c.replyTo || '')
|
||||
setEnabled(c.enabled)
|
||||
setCredential(seedCredential(c, c.transport || 'smtp'))
|
||||
}
|
||||
return c
|
||||
} catch {
|
||||
setError('Could not load email settings.')
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
}, [seedCredential])
|
||||
|
||||
// On mount, surface the outcome of a just-completed connect redirect, strip the
|
||||
// query params so a refresh doesn't replay the banner, then load config.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('email_connected')) {
|
||||
setBanner({ kind: 'ok', text: 'Gmail account connected.' })
|
||||
} else if (params.has('email_error')) {
|
||||
setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' })
|
||||
}
|
||||
if (params.has('email_connected') || params.has('email_error')) {
|
||||
params.delete('email_connected')
|
||||
params.delete('email_error')
|
||||
const qs = params.toString()
|
||||
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
||||
}
|
||||
load(true)
|
||||
}, [load])
|
||||
|
||||
async function connect() {
|
||||
setBusy('connect')
|
||||
setActionError('')
|
||||
try {
|
||||
const { url } = await api.admin.emailConnectUrl()
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not start the connect flow.')
|
||||
setBusy('')
|
||||
}
|
||||
// Switching transport starts from the new one's declared defaults, because the
|
||||
// server does the same: a credential blob is never carried across transports.
|
||||
function changeTransport(id) {
|
||||
setTransport(id)
|
||||
setCredential(seedCredential(config, id))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -113,10 +151,19 @@ export default function EmailDelivery() {
|
||||
setMsg('')
|
||||
setActionError('')
|
||||
try {
|
||||
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
|
||||
const saved = await api.admin.saveEmailConfig({ transport, senderEmail, senderName, replyTo, credential, enabled })
|
||||
setConfig(saved)
|
||||
setEnabled(saved.enabled)
|
||||
setCredential(seedCredential(saved, saved.transport))
|
||||
setMsg('Saved.')
|
||||
} catch (err) {
|
||||
// A refused enable comes back with the reverted config attached, so the
|
||||
// screen shows what is actually stored rather than the state that was
|
||||
// rejected.
|
||||
if (err.body?.config) {
|
||||
setConfig(err.body.config)
|
||||
setEnabled(err.body.config.enabled)
|
||||
}
|
||||
setActionError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
@@ -133,12 +180,13 @@ export default function EmailDelivery() {
|
||||
await load()
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not send the test email.')
|
||||
await load()
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
async function clearCredentials() {
|
||||
setBusy('disconnect')
|
||||
setMsg('')
|
||||
setActionError('')
|
||||
@@ -146,9 +194,11 @@ export default function EmailDelivery() {
|
||||
const c = await api.admin.disconnectEmail()
|
||||
setConfig(c)
|
||||
setEnabled(false)
|
||||
setMsg('Disconnected.')
|
||||
setSenderEmail('')
|
||||
setCredential(seedCredential(c, c.transport))
|
||||
setMsg('Credentials cleared.')
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not disconnect.')
|
||||
setActionError(err.message || 'Could not clear the credentials.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
@@ -157,84 +207,118 @@ export default function EmailDelivery() {
|
||||
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
|
||||
if (!config) return null
|
||||
|
||||
const connected = config.hasRefreshToken
|
||||
const catalog = config.transports || []
|
||||
const selected = catalog.find((t) => t.id === transport)
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
|
||||
<div>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
|
||||
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
|
||||
Sends the contact form through Gmail over OAuth2, delivered to the
|
||||
<strong> Contact email</strong> above. Reuses the Google authentication
|
||||
client — configure that on the Authentication page first.
|
||||
Sends the contact form, invitations, password resets and team
|
||||
notifications. Contact-form mail is delivered to the
|
||||
<strong> Contact email</strong> above. Credentials are stored encrypted
|
||||
and never shown again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{banner && (
|
||||
{config.hadLegacyConnection && !config.hasCredential && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
|
||||
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
|
||||
}}
|
||||
style={{ fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', border: '1px solid #7a6440', color: '#e0b070' }}
|
||||
>
|
||||
{banner.text}
|
||||
This deployment was connected with the old Gmail sign-in, which has been
|
||||
removed. <strong>No mail is being sent.</strong> Enter SMTP credentials
|
||||
below to restore it — for Gmail, use <code>smtp.gmail.com</code> port 587
|
||||
with an app password.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatusPanel config={config} />
|
||||
|
||||
{!config.googleConfigured && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
|
||||
The Google authentication provider needs a client ID and secret before
|
||||
you can connect a Gmail account.
|
||||
</p>
|
||||
{catalog.length > 1 && (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Transport</span>
|
||||
<select value={transport} onChange={(e) => changeTransport(e.target.value)} className="input">
|
||||
{catalog.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!connected ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
|
||||
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
|
||||
{selected?.help && (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>{selected.help}</p>
|
||||
)}
|
||||
|
||||
{(selected?.credentialFields || []).map((f) => (
|
||||
<CredentialField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={credential[f.key]}
|
||||
isSet={Boolean(config.secretsSet?.[f.key])}
|
||||
onChange={(v) => setCredential((prev) => ({ ...prev, [f.key]: v }))}
|
||||
/>
|
||||
))}
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Send from</span>
|
||||
<input
|
||||
type="email"
|
||||
value={senderEmail}
|
||||
onChange={(e) => setSenderEmail(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
Must be an address this account is allowed to send as, or the relay will
|
||||
reject it. Use <strong>Send test</strong> to confirm.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">From display name (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={senderName}
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder={siteTitle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Reply-To (optional)</span>
|
||||
<input
|
||||
type="email"
|
||||
value={replyTo}
|
||||
onChange={(e) => setReplyTo(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="Leave blank to reply to the sending address"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Enable email sending
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
|
||||
{busy === 'save' ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={sendTest} disabled={busy === 'test' || !config.hasCredential} className="pill">
|
||||
{busy === 'test' ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
{config.hasCredential && (
|
||||
<button onClick={clearCredentials} disabled={busy === 'disconnect'} className="pill">
|
||||
Clear credentials
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Enable email sending
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">From display name (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={senderName}
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder={siteTitle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
|
||||
{busy === 'save' ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
|
||||
{busy === 'test' ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
<button onClick={connect} disabled={busy === 'connect'} className="pill">
|
||||
Reconnect
|
||||
</button>
|
||||
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ minHeight: 18 }}>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
|
||||
433
client/src/routes/admin/views/EngagementAudiences.jsx
Normal file
433
client/src/routes/admin/views/EngagementAudiences.jsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { describeExpression, describeReach, notPlacementError } from '../../../lib/engagementRules.js'
|
||||
|
||||
// Admin → Engagement → Audiences (ENGAGEMENT.md §5.1a, Phase 4b).
|
||||
//
|
||||
// A module declares named sets of users over its own data — "members of a team",
|
||||
// "the governors" — and an operator combines them here into a saved audience a
|
||||
// rule can point at. Core learns no game vocabulary: it knows an id, a label and
|
||||
// a resolver it may call.
|
||||
//
|
||||
// **Composition narrows and never widens**, and that is the whole security
|
||||
// content of this screen:
|
||||
//
|
||||
// • the saved ceiling is DERIVED from the tightest audience in the expression,
|
||||
// not chosen — including for "any of", where the intuitive answer (the widest
|
||||
// of the two) is the wrong one. A ceiling says what an expression is allowed
|
||||
// to reach, not what it will resolve to, so the boolean operator makes no
|
||||
// difference to it.
|
||||
// • two ceilings with no ordering between them (staff and owner, say) have no
|
||||
// answer at all, and the save is refused rather than guessing a side.
|
||||
// • "none of" is only available inside an "all of" group. On its own it would
|
||||
// have to mean "everyone except…" — a broadcast built out of one narrow list.
|
||||
// The composer does not offer it anywhere else, and the server refuses it
|
||||
// anyway.
|
||||
//
|
||||
// The three-level composer here is deliberate: one top-level all-of/any-of, one
|
||||
// level of groups inside it, and audiences at the leaves. The stored grammar
|
||||
// allows more nesting; anything deeper is left to the rule that made it and shown
|
||||
// read-only, the same way the rule editor treats a nested condition.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
|
||||
/** A fresh, empty top-level group. */
|
||||
const blankExpression = () => ({ op: 'and', nodes: [] })
|
||||
|
||||
/** Is this tree one the composer can render — a single group of leaves and not-groups? */
|
||||
function isComposable(node) {
|
||||
if (!node || typeof node !== 'object') return false
|
||||
if (!node.op) return true
|
||||
if (node.op === 'not') return (node.nodes || []).every((n) => n && !n.op)
|
||||
if (node.op !== 'and' && node.op !== 'or') return false
|
||||
return (node.nodes || []).every((n) => n && (!n.op || (n.op === 'not' && (n.nodes || []).every((c) => !c.op))))
|
||||
}
|
||||
|
||||
/** The composer edits a top-level group; a bare leaf is lifted into one. */
|
||||
const toGroup = (expression) =>
|
||||
!expression ? blankExpression() : expression.op ? expression : { op: 'and', nodes: [expression] }
|
||||
|
||||
// ── One leaf: an audience and its declared parameters ──────────────────────
|
||||
|
||||
function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate, first }) {
|
||||
const declared = audiences.find((a) => a.id === node.audienceId)
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
{/* The heading belongs to the group, not to every line in it. */}
|
||||
{first && <span className="field-label">Audience</span>}
|
||||
<select
|
||||
className="select"
|
||||
value={node.audienceId || ''}
|
||||
onChange={(e) => onChange({ audienceId: e.target.value, params: {} })}
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
{audiences.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.label} — reaches at most “{a.ceiling}”</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{(declared?.params || []).map((p) => (
|
||||
<label key={p.id} style={{ flex: '0 1 160px' }}>
|
||||
<span className="field-label">{p.id}{p.required ? ' *' : ''}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={node.params?.[p.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...node,
|
||||
params: {
|
||||
...node.params,
|
||||
// `int` params are sent as numbers: the server type-checks each
|
||||
// declared param, and "3" against an int is a refusal.
|
||||
[p.id]: p.type === 'int' && e.target.value !== '' ? Number(e.target.value) : e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{canNegate && (
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, paddingBottom: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={negated} onChange={onToggleNegate} />
|
||||
exclude
|
||||
</label>
|
||||
)}
|
||||
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.72rem', marginBottom: 6 }} onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The composer ───────────────────────────────────────────────────────────
|
||||
|
||||
function SegmentEditor({ audiences, segment, onSaved, onCancel }) {
|
||||
const [name, setName] = useState(segment?.name || '')
|
||||
const [group, setGroup] = useState(() => toGroup(segment?.expression))
|
||||
const [errors, setErrors] = useState([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const isNew = !segment
|
||||
|
||||
// `not` is only offered under "all of" (§5.1a). Under "any of" the checkbox
|
||||
// disappears rather than being offered and refused.
|
||||
const canNegate = group.op === 'and'
|
||||
|
||||
function setNodes(nodes) {
|
||||
setGroup((g) => ({ ...g, nodes }))
|
||||
}
|
||||
|
||||
function addLeaf() {
|
||||
setNodes([...group.nodes, { audienceId: '', params: {} }])
|
||||
}
|
||||
|
||||
function replaceAt(i, next) {
|
||||
setNodes(group.nodes.map((n, j) => (i === j ? next : n)))
|
||||
}
|
||||
|
||||
function toggleNegate(i) {
|
||||
const node = group.nodes[i]
|
||||
replaceAt(i, node.op === 'not' ? node.nodes[0] : { op: 'not', nodes: [node] })
|
||||
}
|
||||
|
||||
function changeOp(op) {
|
||||
// Switching to "any of" drops the exclusions rather than sending a tree the
|
||||
// server will refuse — and says so, because silently keeping them and failing
|
||||
// at save would be worse than either.
|
||||
const nodes = op === 'or' ? group.nodes.map((n) => (n.op === 'not' ? n.nodes[0] : n)) : group.nodes
|
||||
setGroup({ op, nodes })
|
||||
}
|
||||
|
||||
const expression = useMemo(() => {
|
||||
const nodes = group.nodes.filter((n) => (n.op === 'not' ? n.nodes[0]?.audienceId : n.audienceId))
|
||||
if (!nodes.length) return null
|
||||
if (nodes.length === 1 && !nodes[0].op) return nodes[0]
|
||||
return { op: group.op, nodes }
|
||||
}, [group])
|
||||
|
||||
const localError = expression ? notPlacementError(expression) : null
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
if (!expression) return setErrors(['Add at least one audience.'])
|
||||
if (localError) return setErrors([localError])
|
||||
setBusy(true)
|
||||
try {
|
||||
const body = { name: name.trim(), expression }
|
||||
if (isNew) await api.admin.createEngagementSegment(body)
|
||||
else await api.admin.updateEngagementSegment(segment.id, body)
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save that audience.'])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<div className="field-label" style={{ marginBottom: 14 }}>
|
||||
{isNew ? 'New saved audience' : `Editing “${segment.name}”`}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 280px' }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Governors" />
|
||||
</label>
|
||||
<label style={{ flex: '0 1 200px' }}>
|
||||
<span className="field-label">Combine with</span>
|
||||
<select className="select" value={group.op} onChange={(e) => changeOp(e.target.value)}>
|
||||
<option value="and">all of these</option>
|
||||
<option value="or">any of these</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
{group.nodes.length === 0 && (
|
||||
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
No audiences yet. A saved audience is built out of the lists installed modules declare.
|
||||
</p>
|
||||
)}
|
||||
{group.nodes.map((node, i) => {
|
||||
const negated = node.op === 'not'
|
||||
const leaf = negated ? node.nodes[0] : node
|
||||
return (
|
||||
<LeafRow
|
||||
key={i}
|
||||
first={i === 0}
|
||||
audiences={audiences}
|
||||
node={leaf}
|
||||
negated={negated}
|
||||
canNegate={canNegate}
|
||||
onToggleNegate={() => toggleNegate(i)}
|
||||
onChange={(next) => replaceAt(i, negated ? { op: 'not', nodes: [next] } : next)}
|
||||
onRemove={() => setNodes(group.nodes.filter((_, j) => j !== i))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<button type="button" className="btn btn-sq" onClick={addLeaf} disabled={!audiences.length}>
|
||||
Add an audience
|
||||
</button>
|
||||
{!audiences.length && (
|
||||
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
No module currently declares any. Install one, or use a plain audience on the rule itself.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canNegate ? (
|
||||
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
“Exclude” removes people from what the other rows produced. It is only available under “all
|
||||
of”: on its own it would mean “everyone except…”, which is a way to reach the whole
|
||||
deployment from one narrow list.
|
||||
</p>
|
||||
) : (
|
||||
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
“Any of” takes the tightest limit of the audiences in it, not the widest — combining two
|
||||
lists never reaches further than the narrower one allows.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(errors.length > 0 || localError) && (
|
||||
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
|
||||
{(errors.length ? errors : [localError]).map((e) => <li key={e}>{e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||
{busy ? 'Saving…' : isNew ? 'Create' : 'Save changes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EngagementAudiences() {
|
||||
const [audiences, setAudiences] = useState([])
|
||||
const [segments, setSegments] = useState(null)
|
||||
const [editing, setEditing] = useState(null) // null | { segment } | { segment: null }
|
||||
const [error, setError] = useState('')
|
||||
const [rowError, setRowError] = useState('')
|
||||
const [reach, setReach] = useState({}) // segment id -> preview
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [declared, saved] = await Promise.all([
|
||||
api.admin.engagementAudiences(),
|
||||
api.admin.listEngagementSegments(),
|
||||
])
|
||||
setAudiences(declared.audiences || [])
|
||||
setSegments(saved.segments || [])
|
||||
} catch {
|
||||
setError('Could not load audiences.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const audiencesById = useMemo(
|
||||
() => Object.fromEntries(audiences.map((a) => [a.id, a])),
|
||||
[audiences],
|
||||
)
|
||||
|
||||
async function preview(segment) {
|
||||
try {
|
||||
const counted = await api.admin.previewEngagementReach({ audienceSegmentId: segment.id })
|
||||
setReach((r) => ({ ...r, [segment.id]: counted }))
|
||||
} catch (err) {
|
||||
setReach((r) => ({ ...r, [segment.id]: { count: 0, dormant: true, reason: err.message } }))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(segment) {
|
||||
if (!window.confirm(`Delete “${segment.name}”?`)) return
|
||||
setRowError('')
|
||||
try {
|
||||
await api.admin.deleteEngagementSegment(segment.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
// A 409 here is the interesting case and the message carries the count:
|
||||
// deleting a segment a rule still points at would leave that rule reaching
|
||||
// a different set of people, so it is refused rather than cascaded.
|
||||
setRowError(err.message || 'Could not delete that audience.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!segments) return <Loading />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<section>
|
||||
<SegmentEditor
|
||||
audiences={audiences}
|
||||
segment={editing.segment}
|
||||
onSaved={async () => { setEditing(null); await load() }}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
|
||||
Named sets of people a rule can be pointed at, built out of the lists installed modules
|
||||
declare. A saved audience can only ever narrow — combining two lists never reaches further
|
||||
than the tighter of them allows.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ segment: null })}>
|
||||
New audience
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rowError && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Made of</th>
|
||||
<th className="adm-th">Reaches at most</th>
|
||||
<th className="adm-th">Right now</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{segments.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No saved audiences yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{segments.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)' }}>
|
||||
{s.name}
|
||||
{s.dormant && (
|
||||
<div>
|
||||
<span
|
||||
className="badge"
|
||||
title={`Not declared right now: ${(s.missingAudiences || []).join(', ')}`}
|
||||
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
Dormant
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{describeExpression(s.expression, audiencesById)}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{s.ceiling}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{reach[s.id] ? (
|
||||
describeReach(reach[s.id])
|
||||
) : (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => preview(s)}>
|
||||
Count
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
disabled={!isComposable(s.expression)}
|
||||
title={isComposable(s.expression) ? undefined : 'Nested more deeply than this composer renders'}
|
||||
onClick={() => setEditing({ segment: s })}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => remove(s)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginTop: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>What modules currently declare</div>
|
||||
{audiences.length === 0 ? (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
Nothing. Audiences come from installed modules — core declares none, because core knows no
|
||||
game vocabulary.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
{audiences.map((a) => (
|
||||
<li key={a.id}>
|
||||
<span style={{ color: 'var(--text)' }}>{a.label}</span> — <code>{a.id}</code>, reaches at
|
||||
most “{a.ceiling}”
|
||||
{(a.params || []).length ? ` (${a.params.map((p) => p.id).join(', ')})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
716
client/src/routes/admin/views/EngagementRules.jsx
Normal file
716
client/src/routes/admin/views/EngagementRules.jsx
Normal file
@@ -0,0 +1,716 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
formFromRule,
|
||||
ruleToPayload,
|
||||
audienceChoicesFor,
|
||||
segmentChoicesFor,
|
||||
describeReach,
|
||||
describeRule,
|
||||
audienceWarning,
|
||||
conditionRowsFrom,
|
||||
conditionsFromRows,
|
||||
operatorsForType,
|
||||
} from '../../../lib/engagementRules.js'
|
||||
|
||||
// Admin → Engagement → Rules (ENGAGEMENT.md Phase 4b).
|
||||
//
|
||||
// A rule is trigger → audience → channels → timing, and this is the screen that
|
||||
// writes one. Everything it decides lives in lib/engagementRules.js so it can be
|
||||
// tested; this file renders it and talks to the API.
|
||||
//
|
||||
// Four things about this screen are deliberate and would be wrong the obvious
|
||||
// way round:
|
||||
//
|
||||
// 1. **The on/off switch is not the form.** It is its own request against its
|
||||
// own route, and it does not re-validate the rule. A rule whose module has
|
||||
// been uninstalled is dormant, is the rule an operator most wants stopped,
|
||||
// and is exactly the rule the form would refuse to save.
|
||||
// 2. **A rule's trigger is fixed once it exists.** Its cooldowns, its pending
|
||||
// outbox rows and its send-log history are all about one trigger id.
|
||||
// 3. **Every rule arrives off.** §7.1 Q3 makes rules operator-editable data on
|
||||
// the condition that nothing starts mailing by itself — so a new rule is
|
||||
// created disabled and switched on afterwards, as a separate act.
|
||||
// 4. **The reach preview is a number.** Never a list of people: a
|
||||
// module-declared segment resolves over game data, and this screen is about
|
||||
// mail scheduling.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
const BLANK = {
|
||||
id: null,
|
||||
triggerId: '',
|
||||
name: '',
|
||||
enabled: false,
|
||||
audience: 'owner',
|
||||
audienceSegmentId: null,
|
||||
channels: [],
|
||||
templateKeys: {},
|
||||
conditions: null,
|
||||
cooldownSeconds: 0,
|
||||
delaySeconds: 0,
|
||||
cancelOn: [],
|
||||
maxSendsPerHour: 100,
|
||||
}
|
||||
|
||||
function Dormant({ reasons }) {
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
title={reasons.join('\n')}
|
||||
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
Dormant
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
|
||||
const [form, setForm] = useState(() => (rule ? formFromRule(rule) : { ...BLANK }))
|
||||
const [conditionState, setConditionState] = useState(() => conditionRowsFrom(rule?.conditions))
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const isNew = !form.id
|
||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const trigger = useMemo(
|
||||
() => catalog.triggers.find((t) => t.id === form.triggerId) || null,
|
||||
[catalog.triggers, form.triggerId],
|
||||
)
|
||||
const audienceChoices = audienceChoicesFor(trigger, catalog.ceilings)
|
||||
const segmentChoices = segmentChoicesFor(trigger, catalog.ceilings, segments)
|
||||
const variables = trigger?.variables || []
|
||||
|
||||
// Changing the trigger invalidates the audience and every condition, because
|
||||
// both are stated in the old trigger's vocabulary. Clearing them is the honest
|
||||
// move: keeping a condition on a variable the new trigger never carries would
|
||||
// make the rule fire on nothing, silently (an absent variable fails every
|
||||
// comparison, by design).
|
||||
function pickTrigger(id) {
|
||||
const next = catalog.triggers.find((t) => t.id === id)
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
triggerId: id,
|
||||
audience: next?.audience || 'owner',
|
||||
audienceSegmentId: null,
|
||||
}))
|
||||
setConditionState({ op: 'and', rows: [], editable: true })
|
||||
setPreview(null)
|
||||
}
|
||||
|
||||
function toggleChannel(id) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
channels: f.channels.includes(id) ? f.channels.filter((c) => c !== id) : [...f.channels, id],
|
||||
}))
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
setPreviewing(true)
|
||||
try {
|
||||
setPreview(
|
||||
await api.admin.previewEngagementReach({
|
||||
audience: form.audience,
|
||||
audienceSegmentId: form.audienceSegmentId,
|
||||
triggerId: form.triggerId,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
setPreview({ count: 0, dormant: true, reason: err.message || 'could not be resolved' })
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
setBusy(true)
|
||||
const payload = ruleToPayload({
|
||||
...form,
|
||||
conditions: conditionState.editable
|
||||
? conditionsFromRows(conditionState.op, conditionState.rows, variables)
|
||||
: form.conditions,
|
||||
})
|
||||
try {
|
||||
if (isNew) await api.admin.createEngagementRule(payload)
|
||||
else await api.admin.updateEngagementRule(form.id, payload)
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
// The server sends every problem, not just the first. A form that shows one
|
||||
// makes an operator fix four things in four round trips.
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save the rule.'])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<div className="field-label" style={{ marginBottom: 14 }}>
|
||||
{isNew ? 'New rule' : `Editing “${rule.name}”`}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 280px' }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
{isNew ? (
|
||||
<select className="select" value={form.triggerId} onChange={(e) => pickTrigger(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{catalog.triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label} ({t.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input" value={form.triggerId} readOnly disabled />
|
||||
)}
|
||||
{!isNew && (
|
||||
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||||
A rule keeps its trigger — its cooldowns, queued sends and history are all about this one.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={{ flex: '1 1 280px' }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
placeholder="IDOC warning to the owner"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{trigger?.description && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--muted)' }}>
|
||||
{trigger.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Audience ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Who it reaches</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">Audience</span>
|
||||
<select
|
||||
className="select"
|
||||
value={form.audienceSegmentId ? '' : form.audience}
|
||||
disabled={Boolean(form.audienceSegmentId) || !audienceChoices.length}
|
||||
onChange={(e) => { set({ audience: e.target.value, audienceSegmentId: null }); setPreview(null) }}
|
||||
>
|
||||
{/* Without a trigger there is no ceiling, so there is nothing this
|
||||
may legitimately offer — and a select with zero options renders
|
||||
as a control that is broken rather than as one that is waiting. */}
|
||||
{!audienceChoices.length && <option value="">Choose a trigger first…</option>}
|
||||
{Boolean(form.audienceSegmentId) && <option value="">Using the saved audience →</option>}
|
||||
{audienceChoices.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">…or a saved audience</span>
|
||||
<select
|
||||
className="select"
|
||||
value={form.audienceSegmentId || ''}
|
||||
onChange={(e) => {
|
||||
set({ audienceSegmentId: e.target.value ? Number(e.target.value) : null })
|
||||
setPreview(null)
|
||||
}}
|
||||
>
|
||||
<option value="">None — use the audience on the left</option>
|
||||
{segmentChoices.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn btn-sq" disabled={previewing || !form.triggerId} onClick={runPreview}>
|
||||
{previewing ? 'Counting…' : 'Preview reach'}
|
||||
</button>
|
||||
</div>
|
||||
{preview && (
|
||||
<p
|
||||
className="sans"
|
||||
style={{
|
||||
margin: '10px 0 0',
|
||||
fontSize: '0.84rem',
|
||||
color: preview.permitted === false || preview.dormant ? '#d98b84' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{describeReach(preview)}
|
||||
</p>
|
||||
)}
|
||||
{/* The `members`-with-no-saved-audience trap, said before the save rather
|
||||
than discovered after it. It is the DEFAULT the moment a
|
||||
members-ceiling trigger is chosen, and the rule it produces saves,
|
||||
switches on and mails nobody. */}
|
||||
{!preview && audienceWarning(form) && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--accent)' }}>
|
||||
{audienceWarning(form)}
|
||||
</p>
|
||||
)}
|
||||
{trigger && audienceChoices.length <= 1 && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
This event only permits “{trigger.ceiling}”. The audience a rule may use is capped by the
|
||||
event itself, not by the rule.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Channels ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>How it is delivered</div>
|
||||
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||||
{catalog.channels.map((c) => (
|
||||
<div key={c.id} style={{ flex: '0 1 260px' }}>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.channels.includes(c.id)} onChange={() => toggleChannel(c.id)} />
|
||||
{c.label}
|
||||
</label>
|
||||
{form.channels.includes(c.id) && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ marginTop: 6, width: '100%' }}
|
||||
placeholder="template key (optional)"
|
||||
value={form.templateKeys[c.id] || ''}
|
||||
onChange={(e) => set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
Every channel is opt-in: a rule reaches only the people who turned that channel on for this
|
||||
event in their own notification settings.
|
||||
</p>
|
||||
|
||||
{/* ── Conditions ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Only when…</div>
|
||||
{!conditionState.editable ? (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--accent)' }}>
|
||||
This rule has a nested condition this editor does not render. It is left exactly as it is
|
||||
unless you clear it — flattening it here would change which events fire the rule.
|
||||
</p>
|
||||
<pre
|
||||
style={{ background: 'var(--panel-flat)', border: '1px solid var(--line)', borderRadius: 6, padding: 10, fontSize: '0.76rem', overflowX: 'auto' }}
|
||||
>
|
||||
{JSON.stringify(form.conditions, null, 2)}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => { set({ conditions: null }); setConditionState({ op: 'and', rows: [], editable: true }) }}
|
||||
>
|
||||
Clear and start again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{conditionState.rows.length > 1 && (
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<span className="field-label">Match</span>
|
||||
<select
|
||||
className="select"
|
||||
style={{ maxWidth: 220 }}
|
||||
value={conditionState.op}
|
||||
onChange={(e) => setConditionState((s) => ({ ...s, op: e.target.value }))}
|
||||
>
|
||||
<option value="and">all of these</option>
|
||||
<option value="or">any of these</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{conditionState.rows.map((row, i) => {
|
||||
const type = variables.find((v) => v.name === row.variable)?.type
|
||||
const ops = operatorsForType(catalog.operators, type)
|
||||
const takesValue = row.cmp !== 'present' && row.cmp !== 'absent'
|
||||
const patch = (p) =>
|
||||
setConditionState((s) => ({
|
||||
...s,
|
||||
rows: s.rows.map((r, j) => (i === j ? { ...r, ...p } : r)),
|
||||
}))
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<select
|
||||
className="select"
|
||||
style={{ flex: '1 1 160px' }}
|
||||
value={row.variable}
|
||||
onChange={(e) => patch({ variable: e.target.value })}
|
||||
>
|
||||
<option value="">Variable…</option>
|
||||
{variables.map((v) => (
|
||||
<option key={v.name} value={v.name}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="select"
|
||||
style={{ flex: '1 1 160px' }}
|
||||
value={row.cmp}
|
||||
onChange={(e) => patch({ cmp: e.target.value })}
|
||||
>
|
||||
<option value="">Is…</option>
|
||||
{ops.map((o) => (
|
||||
<option key={o.cmp} value={o.cmp}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{takesValue && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: '2 1 200px' }}
|
||||
value={row.value}
|
||||
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'comma, separated, values' : 'value'}
|
||||
onChange={(e) => patch({ value: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => setConditionState((s) => ({ ...s, rows: s.rows.filter((_, j) => j !== i) }))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={!variables.length}
|
||||
onClick={() =>
|
||||
setConditionState((s) => ({ ...s, rows: [...s.rows, { variable: '', cmp: '', value: '' }] }))
|
||||
}
|
||||
>
|
||||
Add a condition
|
||||
</button>
|
||||
{!variables.length && (
|
||||
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
Choose a trigger first — its declared variables are what a condition can talk about.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Timing and the ceiling ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Timing</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="field-label">Wait before sending (seconds)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.delaySeconds}
|
||||
onChange={(e) => set({ delaySeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="field-label">At most once per (seconds)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.cooldownSeconds}
|
||||
onChange={(e) => set({ cooldownSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="field-label">Hard cap (sends per hour)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.maxSendsPerHour}
|
||||
onChange={(e) => set({ maxSendsPerHour: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
The cooldown is per recipient and per subject
|
||||
{trigger?.subjectKey ? ` (“${trigger.subjectKey}”)` : ''} — a player whose four houses are all
|
||||
decaying hears about all four, once each. The hourly cap is per rule and is the hard stop that
|
||||
keeps a misconfiguration to a bad hour.
|
||||
</p>
|
||||
|
||||
{form.delaySeconds > 0 && (
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<span className="field-label">Cancel the wait if any of these happen</span>
|
||||
<select
|
||||
className="select"
|
||||
multiple
|
||||
size={Math.min(5, Math.max(2, catalog.triggers.length))}
|
||||
value={form.cancelOn}
|
||||
onChange={(e) => set({ cancelOn: [...e.target.selectedOptions].map((o) => o.value) })}
|
||||
>
|
||||
{catalog.triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||||
Only meaningful with a wait — there is no window to cancel otherwise, and the save says so.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
|
||||
{errors.map((e) => <li key={e}>{e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||
{busy ? 'Saving…' : isNew ? 'Create rule (off)' : 'Save changes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
{isNew && (
|
||||
<span className="sans" style={{ alignSelf: 'center', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
A new rule is created switched off. Turn it on from the list when you are happy with it.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ── The Phase 6 migration notice ───────────────────────────────────────────
|
||||
//
|
||||
// Team notifications used to be sent with no operator configuration at all;
|
||||
// ENGAGEMENT.md Phase 6 moved them onto rules, and the org lead's decision was to
|
||||
// seed those rules DISABLED rather than carve an exception into "nothing is on by
|
||||
// default". The consequence is a deployment whose Team email has stopped and
|
||||
// nobody has been told — which is G22's failure mode with a different cause — so
|
||||
// the screen that can fix it says so.
|
||||
//
|
||||
// It reads the RULES rather than a flag, so it disappears the moment one is
|
||||
// switched on and comes back if every one is switched off again. A deployment
|
||||
// that deleted them all sees nothing, which is right: they made that choice.
|
||||
//
|
||||
// **Phase 11 added a second notice of exactly the same shape, for news**
|
||||
// (ENGAGEMENT.md §7.1 Q9). Publishing a news post used to tickle every subscriber
|
||||
// directly, and that call is now an emit through the engine, so news push stops
|
||||
// on upgrade until the seeded `news.post` rule is switched on. Two notices rather
|
||||
// than one generalised "some rules are off" banner, deliberately: each names a
|
||||
// capability that USED to work without configuration and now does not, which is
|
||||
// a different statement from "you have a disabled rule" — and a rule an operator
|
||||
// created and disabled themselves must never produce a warning.
|
||||
const TEAM_TRIGGERS = [
|
||||
'team.forum.post',
|
||||
'team.announcement',
|
||||
'team.member.joined',
|
||||
'team.leadership.changed',
|
||||
]
|
||||
|
||||
const NEWS_TRIGGERS = ['news.post']
|
||||
|
||||
// One style for both notices, so the pair reads as one kind of message rather
|
||||
// than two that happen to look alike.
|
||||
const NOTICE_STYLE = {
|
||||
fontSize: '0.85rem',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
marginBottom: 16,
|
||||
border: '1px solid #7a6440',
|
||||
color: '#e0b070',
|
||||
}
|
||||
|
||||
const triggerOf = (rule) => rule.triggerId || rule.trigger_id
|
||||
|
||||
// True only when rules for these triggers EXIST and every one of them is off.
|
||||
// Zero matching rules means the operator deleted them, which is a choice, not a
|
||||
// regression to warn about.
|
||||
function allOff(rules, triggers) {
|
||||
const group = rules.filter((r) => triggers.includes(triggerOf(r)))
|
||||
return group.length > 0 && group.every((r) => !r.enabled)
|
||||
}
|
||||
|
||||
const teamRulesAllOff = (rules) => allOff(rules, TEAM_TRIGGERS)
|
||||
const newsRulesAllOff = (rules) => allOff(rules, NEWS_TRIGGERS)
|
||||
|
||||
export default function EngagementRules() {
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [segments, setSegments] = useState([])
|
||||
const [rules, setRules] = useState(null)
|
||||
const [editing, setEditing] = useState(null) // null | { rule } | { rule: null } for new
|
||||
const [error, setError] = useState('')
|
||||
const [rowError, setRowError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [triggers, channels, segs, list] = await Promise.all([
|
||||
api.admin.engagementTriggers(),
|
||||
api.admin.engagementChannels(),
|
||||
api.admin.listEngagementSegments(),
|
||||
api.admin.listEngagementRules(),
|
||||
])
|
||||
setCatalog({
|
||||
triggers: triggers.triggers || [],
|
||||
ceilings: triggers.ceilings || [],
|
||||
operators: triggers.operators || [],
|
||||
channels: channels.channels || [],
|
||||
})
|
||||
setSegments(segs.segments || [])
|
||||
setRules(list.rules || [])
|
||||
} catch {
|
||||
setError('Could not load the engagement rules.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const segmentsById = useMemo(
|
||||
() => Object.fromEntries(segments.map((s) => [s.id, s])),
|
||||
[segments],
|
||||
)
|
||||
|
||||
async function toggle(rule) {
|
||||
setRowError('')
|
||||
try {
|
||||
await api.admin.setEngagementRuleEnabled(rule.id, !rule.enabled)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.message || 'Could not change that rule.')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(rule) {
|
||||
if (!window.confirm(`Delete “${rule.name}”? Its queued sends go with it; the send log does not.`)) return
|
||||
setRowError('')
|
||||
try {
|
||||
await api.admin.deleteEngagementRule(rule.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.message || 'Could not delete that rule.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!catalog || !rules) return <Loading />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<section>
|
||||
<RuleEditor
|
||||
catalog={catalog}
|
||||
segments={segments}
|
||||
rule={editing.rule}
|
||||
onSaved={async () => { setEditing(null); await load() }}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{teamRulesAllOff(rules) && (
|
||||
<div className="sans" style={NOTICE_STYLE}>
|
||||
<strong>Team notification emails are off.</strong> They used to be sent automatically; they
|
||||
are now rules, and the four below arrived switched off so that nothing starts mailing on its
|
||||
own. Switch on the ones this deployment wants — per-member preferences and per-Team mutes
|
||||
still apply above them, and unsubscribe links in mail already sent still work.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newsRulesAllOff(rules) && (
|
||||
<div className="sans" style={NOTICE_STYLE}>
|
||||
<strong>News notifications are off.</strong> Publishing a news post used to send a push
|
||||
notification to everyone subscribed to it. That is now the “News posts” rule below, and it
|
||||
arrived switched off for the same reason the Team rules did. Switch it on to resume news
|
||||
push — it also carries email and the in-app inbox, each still subject to each person’s own
|
||||
preferences. The in-game town crier and the Discord announcement are unaffected either way.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
|
||||
A rule turns an event into mail: which event, who hears about it, on which channels, and how
|
||||
often at most. Nothing sends until a rule is switched on.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ rule: null })}>
|
||||
New rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rowError && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Rule</th>
|
||||
<th className="adm-th">Trigger</th>
|
||||
<th className="adm-th">What it does</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No rules yet. Nothing is being sent.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)' }}>{rule.name}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{rule.trigger_id}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{describeRule(rule, { segmentsById })}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={Boolean(rule.enabled)} onChange={() => toggle(rule)} />
|
||||
{rule.enabled ? 'On' : 'Off'}
|
||||
</label>
|
||||
{rule.dormant && (
|
||||
<div style={{ marginTop: 4 }}><Dormant reasons={rule.dormantReasons || []} /></div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => setEditing({ rule })}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => remove(rule)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rules.some((r) => r.dormant) && (
|
||||
<p className="sans" style={{ marginTop: 12, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
A dormant rule names something that is not registered right now — usually a module that has
|
||||
been uninstalled. It is kept exactly as it is, it never fires, and it starts working again
|
||||
when the module comes back. It can still be switched off.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
172
client/src/routes/admin/views/EngagementSendLog.jsx
Normal file
172
client/src/routes/admin/views/EngagementSendLog.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Send Log (ENGAGEMENT.md §4.5, gap G15, Phase 5b).
|
||||
//
|
||||
// G15 was stated as: "no per-message record — no send log, no delivery status, no
|
||||
// audit". The table has been filling since Phase 4a; this is the screen that reads
|
||||
// it, and the question it exists to answer is the operator's, not the engine's:
|
||||
// **did that person get that mail, and if not, why not?**
|
||||
//
|
||||
// Two things it deliberately does not show.
|
||||
//
|
||||
// • **The address.** The log stores a sha256 so a bounce can be correlated back
|
||||
// to a recipient (Phase 9) without becoming a second address book. The route
|
||||
// strips the column; this screen could not render it if it wanted to.
|
||||
// • **A name for the user.** The `user_id` is what the log holds, and joining
|
||||
// users in would make a delivery screen into a directory. The id is enough to
|
||||
// paste into Moderation, which is where a person's record belongs.
|
||||
//
|
||||
// `failed` rows are the point of the screen, so the reason is a column and not a
|
||||
// tooltip: a delivery log whose failures need a hover is a log nobody reads.
|
||||
|
||||
const STATUS_LABEL = {
|
||||
sent: 'Sent',
|
||||
failed: 'Failed',
|
||||
suppressed: 'Not sent',
|
||||
bounced: 'Bounced',
|
||||
complained: 'Marked as spam',
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
bounced: '#d98b84',
|
||||
complained: '#d98b84',
|
||||
}
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
export default function EngagementSendLog() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [testTrigger, setTestTrigger] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async (nextOffset, nextStatus) => {
|
||||
const result = await api.admin.listEngagementSends({
|
||||
limit: PAGE,
|
||||
offset: nextOffset,
|
||||
status: nextStatus || undefined,
|
||||
})
|
||||
setRows(result.sends || [])
|
||||
setTotal(result.total || 0)
|
||||
setTestTrigger(result.testSendTrigger || '')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(offset, status)
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [load, offset, status])
|
||||
|
||||
if (loading && rows.length === 0) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const to = Math.min(offset + PAGE, total)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 560 }}>
|
||||
Every message this deployment tried to deliver, successful or not. Addresses are not kept
|
||||
here — only a one-way hash, so a bounce can be matched back without the log becoming a
|
||||
second address book.
|
||||
</p>
|
||||
<label>
|
||||
<span className="field-label">Show</span>
|
||||
<select className="select" value={status} onChange={(e) => { setOffset(0); setStatus(e.target.value) }}>
|
||||
<option value="">Everything</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="suppressed">Not sent</option>
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="complained">Marked as spam</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">When</th>
|
||||
<th className="adm-th">What</th>
|
||||
<th className="adm-th">To</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Result</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{/* The synthetic test-send id is rendered by name: it is not a
|
||||
registered trigger and will never appear in the catalog,
|
||||
so showing the raw id would send someone looking for it. */}
|
||||
{r.trigger_id === testTrigger
|
||||
? <span>Test send <span className="dim">from the template editor</span></span>
|
||||
: <code style={{ fontSize: '0.8rem' }}>{r.trigger_id}</code>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.user_id ? <span className="dim">user #{r.user_id}</span> : <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.channel}
|
||||
{r.transport && <span className="dim"> · {r.transport}</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
|
||||
{STATUS_LABEL[r.status] || r.status}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{r.detail || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
||||
Newer
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
|
||||
Older
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
259
client/src/routes/admin/views/EngagementSuppressions.jsx
Normal file
259
client/src/routes/admin/views/EngagementSuppressions.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Suppressions (ENGAGEMENT.md §4.5 gap G16, Phase 9).
|
||||
//
|
||||
// **This screen is the only way out of the suppression list**, which is the whole
|
||||
// reason it exists rather than the list living as a filter on the Send Log. A
|
||||
// hard bounce is written by a background worker with no human in the loop, so
|
||||
// without a lift button a mistyped-then-corrected mailbox is silenced for good
|
||||
// and nobody ever finds out why that person stopped hearing from the deployment.
|
||||
//
|
||||
// **Addresses are shown masked, and the mask is deliberate on both ends.** The
|
||||
// table holds a sha256 and an `address_masked` — `d***@example.com` — and the
|
||||
// route never returns the hash, for the same reason the Send Log strips it: a
|
||||
// digest of every address on the deployment, handed to a browser, is an offline
|
||||
// dictionary attack waiting to be run. The domain survives because the signal an
|
||||
// operator is actually hunting is domain-shaped ("everything to this company is
|
||||
// bouncing" is a different problem from three people mistyping their own
|
||||
// address), and the local part is destroyed rather than shortened so the list can
|
||||
// never be read back as an address book.
|
||||
//
|
||||
// The consequence to keep in mind while reading this file: **lifting a
|
||||
// suppression needs the WHOLE address typed in**, because the screen genuinely
|
||||
// does not have it. That is not a rough edge to be smoothed later — it is the
|
||||
// privacy design working, and the confirm dialog says so.
|
||||
|
||||
const REASON_LABEL = {
|
||||
bounce: 'Hard bounce',
|
||||
complaint: 'Marked as spam',
|
||||
manual: 'Added by an admin',
|
||||
unverified: 'Unverified',
|
||||
}
|
||||
|
||||
const REASON_HELP = {
|
||||
bounce: 'The receiving server said this mailbox does not exist.',
|
||||
complaint: 'The recipient reported a message as spam.',
|
||||
manual: 'Somebody here added it — usually a bounce reported another way.',
|
||||
unverified: 'Reserved: the verification gate excludes these before a send is queued.',
|
||||
}
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
export default function EngagementSuppressions() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [byReason, setByReason] = useState({})
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [reason, setReason] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
// Debounced separately from `search` so typing a domain does not fire a request
|
||||
// per keystroke; `search` is what the input shows, `applied` is what was asked.
|
||||
const [applied, setApplied] = useState('')
|
||||
const [adding, setAdding] = useState('')
|
||||
const [note, setNote] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async (nextOffset, nextReason, nextSearch) => {
|
||||
const result = await api.admin.listEngagementSuppressions({
|
||||
limit: PAGE,
|
||||
offset: nextOffset,
|
||||
reason: nextReason || undefined,
|
||||
search: nextSearch || undefined,
|
||||
})
|
||||
setRows(result.suppressions || [])
|
||||
setTotal(result.total || 0)
|
||||
setByReason(result.byReason || {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setOffset(0); setApplied(search.trim()) }, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(offset, reason, applied)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [load, offset, reason, applied])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
async function addByHand(e) {
|
||||
e.preventDefault()
|
||||
const address = adding.trim()
|
||||
if (!address) return
|
||||
setNote(null)
|
||||
try {
|
||||
const result = await api.admin.suppressAddress(address)
|
||||
// `created: false` is not a failure — the operator asked for the address to
|
||||
// be suppressed and it is. Saying so plainly beats an error dialog for an
|
||||
// outcome that is exactly what was wanted.
|
||||
setNote(result.created
|
||||
? `${result.address} will no longer be mailed.`
|
||||
: `${result.address} was already suppressed.`)
|
||||
setAdding('')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function lift() {
|
||||
// The address cannot come from the row — the screen has only the mask. Asking
|
||||
// for it in full is the cost of not storing it, and the prompt says why so it
|
||||
// does not read as a missing feature.
|
||||
const address = window.prompt(
|
||||
'Type the full address to let it be mailed again.\n\n'
|
||||
+ 'Suppressed addresses are stored one-way, so this screen never has the address itself.',
|
||||
)
|
||||
if (!address || !address.trim()) return
|
||||
setNote(null)
|
||||
try {
|
||||
await api.admin.unsuppressAddress(address.trim())
|
||||
setNote(`${address.trim()} can be mailed again.`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const to = Math.min(offset + PAGE, total)
|
||||
const summary = Object.entries(byReason).filter(([, n]) => n > 0)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
|
||||
Addresses this deployment has stopped mailing. Engagement rules skip them; password resets,
|
||||
invites and verification mails still go out, because those are asked for by the person
|
||||
themselves. Addresses are stored one-way and shown masked.
|
||||
</p>
|
||||
|
||||
{summary.length > 0 && (
|
||||
<div className="panel-flat" style={{ display: 'flex', gap: 24, flexWrap: 'wrap', padding: '12px 16px', marginBottom: 16 }}>
|
||||
{summary.map(([r, n]) => (
|
||||
<div key={r}>
|
||||
<div className="sans" style={{ fontSize: '1.1rem', fontWeight: 600 }}>{n}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem' }} title={REASON_HELP[r] || ''}>
|
||||
{REASON_LABEL[r] || r}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">Search</span>
|
||||
<input
|
||||
className="input"
|
||||
value={search}
|
||||
placeholder="a domain, or part of one"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Reason</span>
|
||||
<select className="select" value={reason} onChange={(e) => { setOffset(0); setReason(e.target.value) }}>
|
||||
<option value="">Any</option>
|
||||
{Object.keys(REASON_LABEL).map((r) => (
|
||||
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<form onSubmit={addByHand} style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flex: '1 1 280px' }}>
|
||||
<label style={{ flex: 1 }}>
|
||||
<span className="field-label">Suppress an address</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
value={adding}
|
||||
placeholder="someone@example.com"
|
||||
onChange={(e) => setAdding(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="pill" style={{ fontSize: '0.74rem' }} disabled={!adding.trim()}>
|
||||
Suppress
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} onClick={lift}>
|
||||
Lift a suppression
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', margin: '0 0 14px' }}>{note}</p>
|
||||
)}
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Address</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Since</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.channel}:${r.address_masked}:${r.created_at}`}>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.address_masked
|
||||
? <code style={{ fontSize: '0.8rem' }}>{r.address_masked}</code>
|
||||
: <span className="dim">not recorded</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }} title={REASON_HELP[r.reason] || ''}>
|
||||
{REASON_LABEL[r.reason] || r.reason}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{r.detail || ''}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{r.channel}</td>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
||||
Newer
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
|
||||
Older
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
649
client/src/routes/admin/views/EngagementTemplates.jsx
Normal file
649
client/src/routes/admin/views/EngagementTemplates.jsx
Normal file
@@ -0,0 +1,649 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { getEmailBlock, listEmailBlocks, newEmailBlock } from '../../../emailBlocks/index.js'
|
||||
|
||||
// Admin → Engagement → Templates (ENGAGEMENT.md §4.6.2, Phase 5b).
|
||||
//
|
||||
// Phase 5a moved every subject and body out of `mailer.js` into rows. This is the
|
||||
// screen that lets someone change one, and its whole shape follows from a single
|
||||
// fact about email:
|
||||
//
|
||||
// **the server renders the mail, so the server renders the preview.**
|
||||
//
|
||||
// There is no React renderer for an `email.*` block anywhere in this client. The
|
||||
// preview is HTML the server produced with the same call the send path uses,
|
||||
// dropped into a sandboxed iframe. That costs a round trip per edit — debounced
|
||||
// below — and buys the only property that matters on a screen like this: what is
|
||||
// on screen is what will arrive, not a second implementation's opinion of it.
|
||||
//
|
||||
// **The sandbox is a security boundary, not a nicety.** The preview is
|
||||
// operator-authored HTML. It renders with `sandbox` and no `allow-scripts`, from
|
||||
// `srcdoc` (an opaque origin), so it can neither run script nor reach this page's
|
||||
// cookies even if someone stores markup that gets past `sanitizeHtml`. The
|
||||
// attributes are asserted in `client/test/emailTemplates.test.js` for the same
|
||||
// reason the server's checks are asserted: this is the kind of attribute someone
|
||||
// removes while debugging and does not put back.
|
||||
//
|
||||
// What the operator can do here is deliberately bounded (settled with the org
|
||||
// lead at the start of the phase):
|
||||
//
|
||||
// • **A shipped default is edited in place.** `protected` blocks deletion and
|
||||
// nothing else; saving sets `customized = 1`, which is what stops the next
|
||||
// seed bump from taking the edit back.
|
||||
// • **Duplicate is the only way to a new template**, so every template on a
|
||||
// deployment descends from one that renders.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
|
||||
// Three widths, because a mail body has to survive all of them and the failures
|
||||
// are different: 640 is a desktop client's reading pane, 360 is a phone, and the
|
||||
// plain-text part is what a text-only client and every screen reader gets.
|
||||
const WIDTHS = [
|
||||
['desktop', 'Desktop', 640],
|
||||
['mobile', 'Mobile', 360],
|
||||
]
|
||||
|
||||
/** Short, human label for a template's channel. */
|
||||
const CHANNEL_LABEL = { email: 'Email', inapp: 'On the site', push: 'Push' }
|
||||
|
||||
// ── The preview frame ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The rendered HTML, in a sandboxed frame.
|
||||
*
|
||||
* `dark` applies a CSS inversion to the FRAME, not to the mail: it approximates
|
||||
* what Apple Mail and Outlook do to a light-only message, which is the failure
|
||||
* §4.6.2 asks this control to expose ("a light-only template renders as unreadable
|
||||
* dark-on-dark in about a third of inboxes"). It is an approximation and says so
|
||||
* on screen — the alternative, rendering a second dark palette server-side, would
|
||||
* be a preview of a mail this system does not send.
|
||||
*/
|
||||
function PreviewFrame({ html, width, dark }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: dark ? '#1b1b1b' : '#f4f4f5',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
// No allow-scripts, and no allow-same-origin. Both omissions are load
|
||||
// bearing; see this file's header.
|
||||
sandbox=""
|
||||
srcDoc={html || ''}
|
||||
title="Message preview"
|
||||
style={{
|
||||
width,
|
||||
maxWidth: '100%',
|
||||
height: 520,
|
||||
border: '1px solid var(--rule)',
|
||||
borderRadius: 4,
|
||||
background: '#fff',
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
filter: dark ? 'invert(1) hue-rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TemplateEditor({ template, triggers, onDone, onCancel }) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [subject, setSubject] = useState(template.subject || '')
|
||||
const [blocks, setBlocks] = useState(template.blocks || [])
|
||||
const [textBody, setTextBody] = useState(template.text_body || '')
|
||||
const [status, setStatus] = useState(template.status)
|
||||
const [triggerId, setTriggerId] = useState(template.trigger_id || '')
|
||||
const [selected, setSelected] = useState(template.blocks?.[0]?.id || null)
|
||||
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [tab, setTab] = useState('html')
|
||||
const [width, setWidth] = useState('desktop')
|
||||
const [dark, setDark] = useState(false)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [testTo, setTestTo] = useState('')
|
||||
const [testState, setTestState] = useState(null)
|
||||
|
||||
// The variable palette. It comes from the server with the row and is refreshed
|
||||
// by every preview, because re-pointing the template at another trigger changes
|
||||
// it and the server is the one that knows what that trigger declares.
|
||||
const [variables, setVariables] = useState(template.variables || [])
|
||||
|
||||
const draft = useMemo(
|
||||
() => ({ name, subject, blocks, textBody: textBody || null, status, triggerId: triggerId || null }),
|
||||
[name, subject, blocks, textBody, status, triggerId],
|
||||
)
|
||||
|
||||
// Debounced preview. The delay is not about server load — it is one small
|
||||
// render — but about the frame: re-mounting an iframe on every keystroke makes
|
||||
// the preview flicker and steals nothing back.
|
||||
const timer = useRef(null)
|
||||
useEffect(() => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
const body = { subject: draft.subject, blocks: draft.blocks, textBody: draft.textBody, triggerId: draft.triggerId }
|
||||
const result = await api.admin.previewEngagementTemplate(template.id, body)
|
||||
setPreview(result)
|
||||
setPreviewError(null)
|
||||
if (Array.isArray(result.variables)) setVariables(result.variables)
|
||||
} catch (err) {
|
||||
// A preview failure is expected while a block is half-edited, so it is
|
||||
// shown where the preview would be rather than as a page-level error.
|
||||
setPreviewError(err.body?.errors?.join(' · ') || err.message)
|
||||
}
|
||||
}, 400)
|
||||
return () => timer.current && clearTimeout(timer.current)
|
||||
}, [draft, template.id])
|
||||
|
||||
const selectedBlock = blocks.find((b) => b.id === selected) || null
|
||||
const selectedDef = selectedBlock ? getEmailBlock(selectedBlock.type) : null
|
||||
|
||||
const updateBlock = (id, props) =>
|
||||
setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, props } : b)))
|
||||
|
||||
const addBlock = (type) => {
|
||||
const block = newEmailBlock(type)
|
||||
if (!block) return
|
||||
setBlocks((bs) => [...bs, block])
|
||||
setSelected(block.id)
|
||||
}
|
||||
|
||||
const move = (id, delta) =>
|
||||
setBlocks((bs) => {
|
||||
const i = bs.findIndex((b) => b.id === id)
|
||||
const j = i + delta
|
||||
if (i < 0 || j < 0 || j >= bs.length) return bs
|
||||
const next = [...bs]
|
||||
;[next[i], next[j]] = [next[j], next[i]]
|
||||
return next
|
||||
})
|
||||
|
||||
const removeBlock = (id) =>
|
||||
setBlocks((bs) => {
|
||||
const next = bs.filter((b) => b.id !== id)
|
||||
if (selected === id) setSelected(next[0]?.id || null)
|
||||
return next
|
||||
})
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setErrors([])
|
||||
setSaved(false)
|
||||
try {
|
||||
await api.admin.updateEngagementTemplate(template.id, draft)
|
||||
setSaved(true)
|
||||
onDone()
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
setTestState({ busy: true })
|
||||
try {
|
||||
const body = { ...draft, to: testTo }
|
||||
const result = await api.admin.testSendEngagementTemplate(template.id, body)
|
||||
setTestState({ ok: true, message: `Sent to ${result.to}.` })
|
||||
} catch (err) {
|
||||
setTestState({ ok: false, message: err.body?.errors?.join(' · ') || err.message })
|
||||
}
|
||||
}
|
||||
|
||||
const widthPx = WIDTHS.find(([id]) => id === width)?.[2] || 640
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: '0 0 4px', fontSize: '1.05rem' }}>{template.name}</h2>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
|
||||
<code>{template.key}</code> · {CHANNEL_LABEL[template.channel] || template.channel}
|
||||
{template.protected && ' · part of the system'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Back</button>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="panel" style={{ padding: 14, marginBottom: 16, borderColor: '#5b2020' }}>
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="sans" style={{ margin: '0 0 4px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{saved && errors.length === 0 && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--muted)' }}>Saved.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 1fr) minmax(320px, 1.2fr)', gap: 22, alignItems: 'start' }}>
|
||||
{/* ── Authoring ── */}
|
||||
<div>
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
{template.channel === 'email' && (
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Subject</span>
|
||||
<input className="input" value={subject} maxLength={300} onChange={(e) => setSubject(e.target.value)} />
|
||||
<VariableButtons variables={variables} onInsert={(t) => setSubject((s) => s + t)} />
|
||||
</label>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
|
||||
{/* "None" is the right default and not a missing value: every
|
||||
transactional template is tied to no trigger — mailer renders
|
||||
it by key with no rule involved. */}
|
||||
<option value="">None — used by key, not by a rule</option>
|
||||
{triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label} ({t.id})</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
The trigger decides which variables this template may use.
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Status</span>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="draft">Draft — the shipped default is sent instead</option>
|
||||
<option value="published">Published — this is what goes out</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Body</div>
|
||||
{blocks.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>No blocks yet. Add one below.</p>
|
||||
)}
|
||||
{blocks.map((b, i) => {
|
||||
const def = getEmailBlock(b.type)
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', marginBottom: 4,
|
||||
borderRadius: 4, cursor: 'pointer',
|
||||
background: b.id === selected ? 'var(--panel-2, rgba(255,255,255,0.05))' : 'transparent',
|
||||
border: `1px solid ${b.id === selected ? 'var(--accent)' : 'transparent'}`,
|
||||
}}
|
||||
onClick={() => setSelected(b.id)}
|
||||
>
|
||||
<span style={{ width: 18, textAlign: 'center' }}>{def?.icon || '?'}</span>
|
||||
<span className="sans" style={{ flex: 1, fontSize: '0.86rem' }}>
|
||||
{/* An unknown type is a client/server version skew, and saying
|
||||
so beats rendering a blank row the operator cannot act on. */}
|
||||
{def ? def.label : `${b.type} (not known to this client)`}
|
||||
</span>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === 0}
|
||||
onClick={(e) => { e.stopPropagation(); move(b.id, -1) }}>↑</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === blocks.length - 1}
|
||||
onClick={(e) => { e.stopPropagation(); move(b.id, 1) }}>↓</button>
|
||||
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.7rem' }}
|
||||
onClick={(e) => { e.stopPropagation(); removeBlock(b.id) }}>×</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
|
||||
{listEmailBlocks().map((def) => (
|
||||
<button key={def.type} type="button" className="pill" title={def.hint}
|
||||
style={{ fontSize: '0.74rem' }} onClick={() => addBlock(def.type)}>
|
||||
+ {def.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBlock && selectedDef?.editor && (
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>{selectedDef.label}</div>
|
||||
<selectedDef.editor
|
||||
props={selectedBlock.props || {}}
|
||||
variables={variables}
|
||||
onChange={(props) => updateBlock(selectedBlock.id, props)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ padding: 18 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Plain-text part (optional override)</span>
|
||||
<textarea
|
||||
className="input" rows={5} value={textBody}
|
||||
placeholder="Leave blank to generate it from the blocks above."
|
||||
onChange={(e) => setTextBody(e.target.value)}
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.82rem' }}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
Every message has both parts. Writing one here REPLACES the generated text entirely.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Preview ── */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'html' ? 1 : 0.6 }}
|
||||
onClick={() => setTab('html')}>HTML</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'text' ? 1 : 0.6 }}
|
||||
onClick={() => setTab('text')}>Plain text</button>
|
||||
{tab === 'html' && (
|
||||
<>
|
||||
<span style={{ width: 10 }} />
|
||||
{WIDTHS.map(([id, label]) => (
|
||||
<button key={id} type="button" className="pill"
|
||||
style={{ fontSize: '0.74rem', opacity: width === id ? 1 : 0.6 }}
|
||||
onClick={() => setWidth(id)}>{label}</button>
|
||||
))}
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: dark ? 1 : 0.6 }}
|
||||
onClick={() => setDark((d) => !d)}>Dark mode</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewError ? (
|
||||
<div className="panel" style={{ padding: 16, borderColor: '#5b2020' }}>
|
||||
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{previewError}</p>
|
||||
</div>
|
||||
) : !preview ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Rendering…</p>
|
||||
) : tab === 'html' ? (
|
||||
<>
|
||||
{template.channel === 'email' && (
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
|
||||
<span className="dim">Subject: </span>{preview.subject || <em className="dim">none</em>}
|
||||
</p>
|
||||
)}
|
||||
<PreviewFrame html={preview.html} width={widthPx} dark={dark} />
|
||||
{dark && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 6 }}>
|
||||
An approximation of how a client that inverts a light-only message will show it.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<pre className="panel" style={{ padding: 16, fontSize: '0.82rem', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||
{preview.text || '(empty — a published template is refused with no text part)'}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{preview?.missing?.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
No example value for: {preview.missing.join(', ')} — these render as nothing here and
|
||||
will carry real values when the message is actually sent.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginTop: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Send a test</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
Sends what is on screen, saved or not, through the configured transport.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input className="input" type="email" placeholder="you@example.com" value={testTo}
|
||||
onChange={(e) => setTestTo(e.target.value)} style={{ flex: 1 }} />
|
||||
<button type="button" className="btn btn-sq" onClick={sendTest} disabled={testState?.busy}>
|
||||
{testState?.busy ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
{testState && !testState.busy && (
|
||||
<p className="sans" style={{ margin: '8px 0 0', fontSize: '0.82rem', color: testState.ok ? 'var(--muted)' : '#d98b84' }}>
|
||||
{testState.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The variable tokens, for the two fields that are not block props. */
|
||||
function VariableButtons({ variables, onInsert }) {
|
||||
if (!variables?.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{variables.map((v) => (
|
||||
<button key={v.name} type="button" className="btn btn-ghost btn-xs"
|
||||
title={`${v.type || 'string'}${v.description ? ` — ${v.description}` : ''}`}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
|
||||
onClick={() => onInsert(`{{${v.name}}}`)}>
|
||||
{v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Duplicate ──────────────────────────────────────────────────────────────
|
||||
|
||||
function DuplicateForm({ source, triggers, onDone, onCancel }) {
|
||||
const [key, setKey] = useState('')
|
||||
const [name, setName] = useState(`${source.name} (copy)`)
|
||||
const [triggerId, setTriggerId] = useState(source.trigger_id || '')
|
||||
const [errors, setErrors] = useState([])
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
try {
|
||||
const { template } = await api.admin.duplicateEngagementTemplate(source.id, { key, name, triggerId: triggerId || null })
|
||||
onDone(template)
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.98rem' }}>Duplicate “{source.name}”</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 16px', fontSize: '0.82rem' }}>
|
||||
The copy starts as a draft, so nothing sends it until you publish it.
|
||||
</p>
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
|
||||
))}
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Key</span>
|
||||
<input className="input" value={key} maxLength={96} placeholder="notify.my-event"
|
||||
onChange={(e) => setKey(e.target.value)} />
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
How a rule points at this template. Lowercase letters, digits, dots and dashes; it cannot be
|
||||
changed afterwards.
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
|
||||
<option value="">None — used by key, not by a rule</option>
|
||||
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} ({t.id})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq">Duplicate</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The list ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EngagementTemplates() {
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [triggers, setTriggers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [rowError, setRowError] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [duplicating, setDuplicating] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [t, tr] = await Promise.all([api.admin.listEngagementTemplates(), api.admin.engagementTriggers()])
|
||||
setTemplates(t.templates || [])
|
||||
setTriggers(tr.triggers || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [load])
|
||||
|
||||
async function open(row) {
|
||||
setRowError(null)
|
||||
try {
|
||||
const { template } = await api.admin.getEngagementTemplate(row.id)
|
||||
setEditing(template)
|
||||
} catch (err) {
|
||||
setRowError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
if (!window.confirm(`Delete “${row.name}”?`)) return
|
||||
setRowError(null)
|
||||
try {
|
||||
await api.admin.deleteEngagementTemplate(row.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.body?.errors?.join(' · ') || err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<TemplateEditor
|
||||
template={editing}
|
||||
triggers={triggers}
|
||||
onDone={load}
|
||||
onCancel={async () => { setEditing(null); await load() }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{duplicating && (
|
||||
<DuplicateForm
|
||||
source={duplicating}
|
||||
triggers={triggers}
|
||||
onCancel={() => setDuplicating(null)}
|
||||
onDone={async (template) => { setDuplicating(null); await load(); setEditing(template) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
|
||||
Every message this deployment sends. The shipped ones are editable — your edits survive
|
||||
upgrades — and cannot be deleted, because the system breaks without them. To make a new
|
||||
template, duplicate one that already works.
|
||||
</p>
|
||||
|
||||
{rowError && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Key</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="adm-td">
|
||||
{t.name}
|
||||
{t.protected && (
|
||||
<span className="pill" style={{ marginLeft: 8, fontSize: '0.68rem' }}>system</span>
|
||||
)}
|
||||
<Flags template={t} />
|
||||
</td>
|
||||
<td className="adm-td"><code style={{ fontSize: '0.8rem' }}>{t.key}</code></td>
|
||||
<td className="adm-td">{CHANNEL_LABEL[t.channel] || t.channel}</td>
|
||||
<td className="adm-td">{t.status === 'published' ? 'Published' : 'Draft'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => open(t)}>Edit</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => setDuplicating(t)}>Duplicate</button>
|
||||
<button type="button" className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem', opacity: t.protected ? 0.4 : 1 }}
|
||||
disabled={t.protected}
|
||||
title={t.protected ? 'Part of the system — edit it or duplicate it' : undefined}
|
||||
onClick={() => remove(t)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three warnings a row can carry. Each is a different fact and they are worded
|
||||
* as what an operator should DO, not as the flag name: "dormant" and "behind" mean
|
||||
* nothing to someone who has not read the design document.
|
||||
*/
|
||||
function Flags({ template }) {
|
||||
const notes = []
|
||||
if (template.dormant) {
|
||||
notes.push(`No installed module declares ${template.trigger_id} — nothing will send this.`)
|
||||
}
|
||||
if (template.triggerBehind) {
|
||||
notes.push('Its trigger has changed since this was written; check the variables still exist.')
|
||||
}
|
||||
if (template.seedBehind) {
|
||||
notes.push('A newer version of the shipped default exists. Your edits were kept, so it was not applied.')
|
||||
}
|
||||
if (!notes.length) return null
|
||||
return (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{notes.map((n) => <div key={n}>{n}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
client/src/routes/admin/views/EngagementTriggers.jsx
Normal file
130
client/src/routes/admin/views/EngagementTriggers.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Triggers (ENGAGEMENT.md §4.3, Phase 5b).
|
||||
//
|
||||
// Read-only, and structurally so: **there is no table behind this screen.** A
|
||||
// trigger is DECLARED in code by core or by an installed module, so this is
|
||||
// whatever registered on the current boot. Uninstall a module and its triggers
|
||||
// stop appearing here; nothing was deleted and nothing needs to be.
|
||||
//
|
||||
// It exists because the two things it shows are otherwise invisible and both are
|
||||
// load-bearing elsewhere:
|
||||
//
|
||||
// • **The variables** are the contract a template may reference. When a rule
|
||||
// mails nothing sensible, "which variables does this event actually carry"
|
||||
// is the first question, and the answer used to live only in a module's source.
|
||||
// • **The ceiling** is the security boundary from G24 — the widest audience a
|
||||
// rule may ever give this trigger. A rule editor that offers a narrower set
|
||||
// than an operator expects is obeying a number declared here.
|
||||
|
||||
const CEILING_NOTE = {
|
||||
owner: 'only the person the event is about',
|
||||
members: 'only members of the thing it is about',
|
||||
subscribers: 'only people who opted in',
|
||||
staff: 'only staff',
|
||||
admin: 'only administrators',
|
||||
authenticated: 'any signed-in account',
|
||||
everyone: 'anyone',
|
||||
}
|
||||
|
||||
export default function EngagementTriggers() {
|
||||
const [triggers, setTriggers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const { triggers: list } = await api.admin.engagementTriggers()
|
||||
if (alive) setTriggers(list || [])
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
|
||||
The events a rule can be built on, declared in code by core and by installed modules. This
|
||||
list is whatever is registered right now — it is not stored anywhere, so a module that is
|
||||
uninstalled simply stops appearing.
|
||||
</p>
|
||||
|
||||
{triggers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing is registered.</p>
|
||||
)}
|
||||
|
||||
{triggers.map((t) => (
|
||||
<div className="panel" key={t.id} style={{ padding: 18, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h3 className="sans" style={{ margin: '0 0 2px', fontSize: '0.98rem' }}>{t.label}</h3>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
<code>{t.id}</code> · from {t.owner} · v{t.version}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="field-label" style={{ marginBottom: 2 }}>Can reach at most</div>
|
||||
<div className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
{t.ceiling}
|
||||
<span className="dim"> — {CEILING_NOTE[t.ceiling] || 'see the design document'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{t.description && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
{t.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(t.variables || []).length > 0 && (
|
||||
<table className="adm-table" style={{ marginTop: 14 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Variable</th>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">Example</th>
|
||||
<th className="adm-th">What it is</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.variables.map((v) => (
|
||||
<tr key={v.name}>
|
||||
{/* `nowrap`: without it the "always set" pill wraps between its
|
||||
two words on a longer variable name, orphaning "set" on a
|
||||
line of its own and making the row read as two facts. */}
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.8rem' }}>{`{{${v.name}}}`}</code>
|
||||
{v.required && <span className="pill" style={{ marginLeft: 6, fontSize: '0.66rem' }}>always set</span>}
|
||||
</td>
|
||||
<td className="adm-td">{v.type}</td>
|
||||
<td className="adm-td" style={{ maxWidth: 260, overflowWrap: 'anywhere' }}>
|
||||
<span className="dim" style={{ fontSize: '0.8rem' }}>
|
||||
{/* A list variable's example is an array of objects; showing
|
||||
it as JSON is honest and short, and it is the shape an
|
||||
item list repeats over. */}
|
||||
{typeof v.example === 'string' ? v.example : JSON.stringify(v.example)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{v.description || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import EmailDelivery from './EmailDelivery.jsx'
|
||||
import TeamForumSettings from './TeamForumSettings.jsx'
|
||||
|
||||
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
|
||||
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TeamForumSettings />
|
||||
|
||||
<EmailDelivery />
|
||||
</section>
|
||||
)
|
||||
|
||||
276
client/src/routes/admin/views/TeamForumSettings.jsx
Normal file
276
client/src/routes/admin/views/TeamForumSettings.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// The operator's Team-forum controls (TEAMS.md §5.5, plus phase 5's edit window),
|
||||
// and the acknowledgement.
|
||||
//
|
||||
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
|
||||
// same reason EmailDelivery is its own: one of these settings has a server-side
|
||||
// PRECONDITION and a confirmation flow, and a control with a precondition inside a
|
||||
// generic list of key/value inputs is one whose behaviour nobody reading that list
|
||||
// would predict.
|
||||
//
|
||||
// **The checkbox below is not the gate.** The server rejects `teams_forum_images =
|
||||
// 'uploads'` with 400 unless the same request carries the acknowledgement version,
|
||||
// and it does so whether or not this dialog was ever rendered. What is here is how
|
||||
// the gate is PRESENTED — the wording an operator agrees to, and the recording of
|
||||
// which version they agreed to.
|
||||
|
||||
// §5.5.5(a). Rendered beneath the selector at ALL times, in every mode: it
|
||||
// explains what the setting is, which is a different job from the confirmation.
|
||||
const HELP_TEXT = [
|
||||
'Image uploads are disabled by default.',
|
||||
'Enabling uploads allows users to store files on infrastructure that you control.',
|
||||
'By enabling this feature, you acknowledge that you are responsible for:',
|
||||
]
|
||||
const HELP_BULLETS = [
|
||||
'Moderating uploaded content',
|
||||
'Managing storage and backups',
|
||||
'Complying with applicable laws and regulations',
|
||||
'Establishing policies for your community',
|
||||
]
|
||||
const HELP_TAIL = [
|
||||
'Runic Gateway does not provide hosted storage or content moderation services. All uploaded content'
|
||||
+ ' is stored on your own infrastructure.',
|
||||
// Addition 1 — the reassuring counterpart, and the reason the attribution table
|
||||
// in §5.5.4 exists at all.
|
||||
'Uploads are attributed to the account that made them, and your staff can remove them at any time.',
|
||||
// Addition 3 — the blast radius. "Users" is doing a lot of work: forum access is
|
||||
// not the same as game membership, so this genuinely surprises.
|
||||
'Anyone with access to a team forum can upload, including members granted access manually who have'
|
||||
+ ' no linked game account.',
|
||||
]
|
||||
|
||||
// §5.5.2's non-blocking advisory for `remote`. Not an acknowledgement — nothing is
|
||||
// stored in that mode — but the operator's server is still doing the displaying.
|
||||
const REMOTE_ADVISORY = 'Images hosted elsewhere are loaded by each visitor’s browser directly from the'
|
||||
+ ' site hosting them. That site can see your visitors’ IP addresses, and you do not control whether'
|
||||
+ ' the image changes or disappears.'
|
||||
|
||||
// §5.5.5(b). Shown only when changing the mode TO uploads.
|
||||
const DIALOG_CHECKS = [
|
||||
'I understand that uploaded files will be stored on infrastructure that I control.',
|
||||
'I understand that I am responsible for community moderation policies on this installation.',
|
||||
]
|
||||
// Addition 2 — the expectation gap most likely to bite. An operator who turns
|
||||
// uploads off because of a problem will assume the problem goes with it.
|
||||
const DIALOG_TAIL = 'Disabling uploads later stops new files being accepted. It does not delete files'
|
||||
+ ' already uploaded — remove those from the forum moderation tools.'
|
||||
|
||||
const MODES = [
|
||||
{ value: 'disabled', label: 'Disabled — image URLs stay plain links' },
|
||||
{ value: 'remote', label: 'Remote — images hosted elsewhere are shown' },
|
||||
{ value: 'uploads', label: 'Uploads — members may upload images to this server' },
|
||||
]
|
||||
|
||||
export default function TeamForumSettings() {
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const [state, setState] = useState(null)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [mode, setMode] = useState('disabled')
|
||||
const [editWindow, setEditWindow] = useState('15')
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await api.admin.teamForumSettings()
|
||||
setState(s)
|
||||
setEnabled(s.enabled)
|
||||
setMode(s.imageMode)
|
||||
setEditWindow(String(s.editWindowMinutes ?? 15))
|
||||
} catch {
|
||||
setError('Could not load forum settings.')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
if (!state) return null
|
||||
|
||||
const stale = state.acknowledgement?.stale
|
||||
|
||||
async function persist(next, acknowledge) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.updateSettings({
|
||||
teams_forums_enabled: next.enabled ? '1' : '0',
|
||||
teams_forum_images: next.mode,
|
||||
teams_forum_edit_window_minutes: String(next.editWindow),
|
||||
...(acknowledge ? { acknowledge } : {}),
|
||||
})
|
||||
setSaved(true)
|
||||
await load()
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save forum settings.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Moving TO uploads asks first; every other change saves directly. A stale
|
||||
// acknowledgement also routes through the dialog, because re-acknowledging is
|
||||
// the only thing that unfreezes these settings.
|
||||
function save() {
|
||||
setSaved(false)
|
||||
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
|
||||
setDialog({ enabled, mode, editWindow })
|
||||
return
|
||||
}
|
||||
if (stale) {
|
||||
setDialog({ enabled, mode, editWindow })
|
||||
return
|
||||
}
|
||||
persist({ enabled, mode, editWindow })
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 620 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Team forums</h2>
|
||||
|
||||
{stale && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#e0b877', margin: '0 0 12px' }}>
|
||||
The image-upload notice has changed since it was accepted
|
||||
{state.acknowledgement.acknowledgedBy ? ` by ${state.acknowledgement.acknowledgedBy}` : ''}.
|
||||
Uploads keep working, but no forum setting can be saved until it is acknowledged again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => { setEnabled(e.target.checked); setSaved(false) }}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="field-label" style={{ display: 'inline' }}>Enable Team forums</span>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
Off by default. Switching forums off hides them completely — every forum route answers “not
|
||||
found” — but deletes nothing: threads, posts, access grants and notification preferences all
|
||||
survive and come back exactly as they were.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Images in forum posts</span>
|
||||
<select value={mode} onChange={(e) => { setMode(e.target.value); setSaved(false) }} className="select">
|
||||
{MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<span className="field-label">Post edit window (minutes)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={0}
|
||||
max={state.editWindowMax ?? 1440}
|
||||
value={editWindow}
|
||||
onChange={(e) => { setEditWindow(e.target.value); setSaved(false) }}
|
||||
style={{ maxWidth: 120 }}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
How long an author may edit their own post after writing it. Staff are not bound by it and
|
||||
may edit at any time. Set it to 0 to make posts permanent once written — a bound of some
|
||||
kind is what stops a post being rewritten out from under someone quoting it, or under a
|
||||
moderator about to act on a report.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
|
||||
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
||||
<ul style={{ margin: '0 0 6px 18px' }}>
|
||||
{HELP_BULLETS.map((b) => <li key={b}>{b}</li>)}
|
||||
</ul>
|
||||
{HELP_TAIL.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
||||
{mode !== 'disabled' && (
|
||||
<p style={{ margin: '0 0 6px', color: '#e0b877' }}>{REMOTE_ADVISORY}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 12, alignItems: 'center' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save forum settings'}
|
||||
</button>
|
||||
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
|
||||
{dialog && (
|
||||
<UploadsDialog
|
||||
version={state.acknowledgement.version}
|
||||
onCancel={() => {
|
||||
setDialog(null)
|
||||
setMode(state.imageMode)
|
||||
setEnabled(state.enabled)
|
||||
setEditWindow(String(state.editWindowMinutes ?? 15))
|
||||
}}
|
||||
onConfirm={async (version) => {
|
||||
setDialog(null)
|
||||
await persist(dialog, version)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two checkboxes, one recorded acknowledgement.
|
||||
*
|
||||
* `Enable uploads` stays disabled until both are ticked, but the request carries a
|
||||
* single version and the stored value is the text VERSION. Recording two booleans
|
||||
* would add nothing — there is no reachable state where an operator consented to
|
||||
* one clause and not the other and proceeded anyway — while the version answers
|
||||
* the question that actually matters later: which text did they agree to?
|
||||
*/
|
||||
function UploadsDialog({ version, onCancel, onConfirm }) {
|
||||
const [checks, setChecks] = useState(DIALOG_CHECKS.map(() => false))
|
||||
const all = checks.every(Boolean)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Enable image uploads"
|
||||
style={{
|
||||
marginTop: 14, padding: 14, border: '1px solid #e0b877', borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontWeight: 600 }}>
|
||||
⚠ Image uploads are currently disabled.
|
||||
</p>
|
||||
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.88rem' }}>
|
||||
Enabling uploads will allow users to store files on your server.
|
||||
</p>
|
||||
{DIALOG_CHECKS.map((text, i) => (
|
||||
<label key={text} className="sans" style={{ display: 'block', fontSize: '0.85rem', marginBottom: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checks[i]}
|
||||
onChange={(e) => setChecks((c) => c.map((v, j) => (j === i ? e.target.checked : v)))}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
{text}
|
||||
</label>
|
||||
))}
|
||||
<p className="sans dim" style={{ margin: '10px 0', fontSize: '0.8rem' }}>{DIALOG_TAIL}</p>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={!all}
|
||||
onClick={() => onConfirm(version)}
|
||||
>
|
||||
Enable uploads
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
client/src/routes/admin/views/TeamIntegrations.jsx
Normal file
292
client/src/routes/admin/views/TeamIntegrations.jsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
|
||||
setChannel, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
|
||||
} from '../../../lib/teamIntegrations.js'
|
||||
|
||||
// The Team notification bridge (TEAMS.md §7.2, phase 8).
|
||||
//
|
||||
// Named for the TEAM concern rather than for Discord, and placed under Teams
|
||||
// rather than in the Discord Bot panel, because phase 10 replaces "Discord" here
|
||||
// with whatever the capability registry declares. What changes then should be
|
||||
// what fills this panel, not where an operator goes to find it. Nothing below
|
||||
// hardcodes the word except the heading the server sends as `platform`.
|
||||
//
|
||||
// **The checkbox in the dialog is not the gate.** The server refuses to enable a
|
||||
// row carrying `team.forum.post` or `team.announcement` without the
|
||||
// acknowledgement, 422, whether or not this dialog was ever rendered — the same
|
||||
// division TeamForumSettings draws for image uploads. What is here is how the
|
||||
// gate is PRESENTED: the sentence an operator agrees to, and the fact that
|
||||
// agreeing is a deliberate act rather than a checkbox they tab past.
|
||||
|
||||
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
|
||||
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
|
||||
|
||||
const ACK_TEXT = [
|
||||
'Forum posts and announcements are visible only to a Team’s members. This site cannot see who can'
|
||||
+ ' read a channel on another platform, so it cannot check that for you.',
|
||||
'By enabling these events you confirm that the destination channel is restricted to the members of'
|
||||
+ ' the Team whose posts it will carry.',
|
||||
]
|
||||
|
||||
export default function TeamIntegrations() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [teams, setTeams] = useState([])
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()])
|
||||
setConfig(cfg)
|
||||
setTeams((teamList.teams || []).filter((t) => t.status === 'active'))
|
||||
} catch (err) {
|
||||
// A moderator never reaches this panel — the admin nav does not render it —
|
||||
// so a 403 here means the role changed underneath an open tab rather than a
|
||||
// routing mistake, and saying so beats "could not load".
|
||||
setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Notification bridge</h2>
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const membersOnlyIds = membersOnlyIdsOf(config.events)
|
||||
const { hasDefault, teams: available } = availableTargets(config.rows, teams)
|
||||
|
||||
async function persist(next) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
await api.admin.saveTeamIntegration({
|
||||
teamId: next.teamId,
|
||||
events: next.events,
|
||||
channelRef: next.channelRef.trim() || null,
|
||||
enabled: next.enabled,
|
||||
membersAck: next.membersAck,
|
||||
})
|
||||
setDraft(null)
|
||||
setDialog(null)
|
||||
setNotice('Saved.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
setDialog(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enabling members-only events without a standing acknowledgement asks first.
|
||||
// Everything else — disabling, editing a channel, adding a roster event — saves
|
||||
// straight through.
|
||||
function save() {
|
||||
if (!draft) return
|
||||
if (needsAcknowledgement(draft, membersOnlyIds)) {
|
||||
setDialog(draft)
|
||||
return
|
||||
}
|
||||
persist(draft)
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.deleteTeamIntegration(row.team_id ?? null)
|
||||
setNotice('Removed.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Notification bridge</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
|
||||
Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
|
||||
override it for individual Teams. A message is sent once and not retried — the bridge is a
|
||||
courtesy, and nothing on the site depends on it arriving.
|
||||
</p>
|
||||
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
|
||||
|
||||
{config.rows.length === 0 && !draft && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured — no Team events leave the site.</p>
|
||||
)}
|
||||
|
||||
{config.rows.length > 0 && (
|
||||
<div className="panel-flat" style={{ overflowX: 'auto' }}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Applies to</th>
|
||||
<th className="adm-th">Events</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.rows.map((row) => (
|
||||
<tr key={rowKey(row)}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||
{appliesToLabel(row)}
|
||||
{isDefaultRow(row) && <span className="dim"> (default)</span>}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
{row.events.length === 0
|
||||
? <span className="dim">none</span>
|
||||
: row.events.map(eventLabel).join(', ')}
|
||||
</td>
|
||||
<td className="adm-td dim">{row.channel_ref || <span className="dim">unset</span>}</td>
|
||||
<td className="adm-td">
|
||||
{row.enabled ? 'Enabled' : 'Disabled'}
|
||||
{row.members_ack && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
|
||||
members-only destination confirmed
|
||||
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
|
||||
<button type="button" className="btn btn-ghost btn-sq" style={{ marginLeft: 8 }} disabled={busy} onClick={() => remove(row)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!draft && (
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
{!hasDefault && (
|
||||
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(null))}>
|
||||
Set a default for all Teams
|
||||
</button>
|
||||
)}
|
||||
{available.length > 0 && (
|
||||
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(available[0].id))}>
|
||||
Add a per-Team override
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft && (
|
||||
<div style={{ marginTop: 18, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Applies to</span>
|
||||
<select
|
||||
className="select"
|
||||
value={draft.teamId === null ? 'default' : String(draft.teamId)}
|
||||
onChange={(e) => setDraft({ ...draft, teamId: e.target.value === 'default' ? null : Number(e.target.value) })}
|
||||
>
|
||||
<option value="default">All Teams (default)</option>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.display_name_override || t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className="field-label">Events to send</span>
|
||||
{config.events.map((event) => (
|
||||
<label key={event.id} style={{ display: 'block', marginTop: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.events.includes(event.id)}
|
||||
onChange={() => setDraft((d) => toggleEvent(d, event.id))}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="sans" style={{ fontSize: '0.82rem' }}>{eventLabel(event.id)}</span>
|
||||
{event.membersOnly && (
|
||||
<span className="dim sans" style={{ fontSize: '0.72rem', marginLeft: 8 }}>members-only content</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<span className="field-label">Channel id</span>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.channelRef}
|
||||
// Changing the channel drops a standing acknowledgement in the SAME
|
||||
// place the server does. Leaving the tick showing while the server
|
||||
// has already decided to clear it would let an operator repoint a row
|
||||
// at a public channel and believe the confirmation still covered it.
|
||||
onChange={(e) => setDraft((d) => setChannel(d, e.target.value))}
|
||||
placeholder="1024839201048392010"
|
||||
style={{ maxWidth: 280 }}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
Right-click a channel in {config.platform} and copy its id. Changing it asks you to confirm
|
||||
the new channel’s audience again.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled}
|
||||
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="field-label" style={{ display: 'inline' }}>Enabled</span>
|
||||
</label>
|
||||
|
||||
{draft.membersAck && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
|
||||
You have confirmed this channel is restricted to the Team’s members.{' '}
|
||||
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft({ ...draft, membersAck: false })}>
|
||||
Withdraw
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<div style={{ marginTop: 18, border: '1px solid #e0b070', padding: 16, borderRadius: 'var(--radius-input)' }}>
|
||||
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destination’s audience</h3>
|
||||
{ACK_TEXT.map((line) => (
|
||||
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => persist({ ...dialog, membersAck: true })}
|
||||
>
|
||||
I confirm the channel is members-only
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
273
client/src/routes/admin/views/TeamVoice.jsx
Normal file
273
client/src/routes/admin/views/TeamVoice.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
|
||||
parseStaffRoles, formatStaffRoles, statusSummary,
|
||||
} from '../../../lib/teamVoice.js'
|
||||
|
||||
// Team voice channels (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// Named for the Team concern and placed under Teams beside the notification
|
||||
// bridge, for the reason that panel gives: phase 10 replaces "Discord" with
|
||||
// whatever the capability registry declares, and what should change then is what
|
||||
// fills this panel rather than where an operator goes to find it.
|
||||
//
|
||||
// **The preflight is the first thing on the page, not a diagnostic.** §7.3
|
||||
// assumed the bot could manage channels and roles; nothing in this project has
|
||||
// ever checked, because the operator invites the bot by hand and no invite URL
|
||||
// with a permission integer exists anywhere in the tree. An operator whose bot
|
||||
// lacks Manage Roles otherwise has a screen full of controls that cannot work,
|
||||
// and finds out one Team at a time from a column of identical errors.
|
||||
|
||||
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
|
||||
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
|
||||
|
||||
export default function TeamVoice() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const cfg = await api.admin.teamVoice()
|
||||
setConfig(cfg)
|
||||
setDraft({
|
||||
enabled: cfg.settings.enabled,
|
||||
minMembers: cfg.settings.minMembers,
|
||||
graceDays: cfg.settings.graceDays,
|
||||
staffRoles: formatStaffRoles(cfg.settings.staffRoles),
|
||||
})
|
||||
} catch (err) {
|
||||
// A moderator never reaches this panel — the admin nav does not render it —
|
||||
// so a 403 means the role changed underneath an open tab.
|
||||
setError(err.status === 403
|
||||
? 'Only an admin can configure Team voice channels.'
|
||||
: (err.message || 'Could not load the voice configuration.'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!config || !draft) {
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Voice channels</h2>
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const blocked = enableBlockedReason(config.preflight)
|
||||
const headroom = roleHeadroom(config.preflight)
|
||||
|
||||
async function save() {
|
||||
const { roles, invalid } = parseStaffRoles(draft.staffRoles)
|
||||
if (invalid.length > 0) {
|
||||
setError(`Not a role id: ${invalid.join(', ')}. Copy role ids from Discord with Developer Mode on.`)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
await api.admin.saveTeamVoice({
|
||||
enabled: draft.enabled,
|
||||
minMembers: Number(draft.minMembers),
|
||||
graceDays: Number(draft.graceDays),
|
||||
staffRoles: roles,
|
||||
})
|
||||
setNotice('Saved.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function runPass() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const result = await api.admin.teamVoicePass()
|
||||
// A pass that refused says why, and that is the useful answer far more often
|
||||
// than a count is — "stale projection" and "synced 0" look identical in a
|
||||
// summary and mean completely different things.
|
||||
setNotice(result.ran
|
||||
? `Synced ${result.synced}, created ${result.created}, scheduled ${result.scheduled}, removed ${result.removed}, failed ${result.failed}.`
|
||||
: `Nothing was done: ${result.reason}`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not run a pass.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.removeTeamVoice(row.teamId)
|
||||
setNotice('Removed.')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Voice channels</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
|
||||
Give each Team a {config.platform} voice channel of its own. Access is granted with a role per
|
||||
Team, so members of a Team can see and join their channel and nobody else can. Members need a
|
||||
linked {config.platform} account and must be in the guild.
|
||||
</p>
|
||||
|
||||
{blocked && (
|
||||
<p className="sans" style={{ color: '#e0b070', fontSize: '0.82rem' }}>
|
||||
{blocked} Voice channels cannot be switched on until that is fixed.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{headroom && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{headroom.used} of {headroom.cap} {config.platform} roles used in this guild
|
||||
{headroom.exhausted
|
||||
? ' — no room for another Team.'
|
||||
: headroom.tight
|
||||
? ` — room for about ${headroom.free} more Teams.`
|
||||
: '.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||||
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
|
||||
|
||||
<p className="sans" style={{ fontSize: '0.8rem' }}>{statusSummary(config.settings, config.rows)}</p>
|
||||
|
||||
<div style={{ marginTop: 14, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
|
||||
<label className="sans" style={{ display: 'block', marginBottom: 12, fontSize: '0.82rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled}
|
||||
disabled={busy || (!!blocked && !draft.enabled)}
|
||||
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||||
/>
|
||||
{' '}Provision voice channels for Teams
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Minimum members</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
value={draft.minMembers}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDraft({ ...draft, minMembers: e.target.value })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
|
||||
Every active member counts, whether or not they have linked an account.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Grace window (days)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="90"
|
||||
value={draft.graceDays}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDraft({ ...draft, graceDays: e.target.value })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
|
||||
How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the
|
||||
window keeps the same channel; zero removes it on the next pass.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Staff roles</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={draft.staffRoles}
|
||||
disabled={busy}
|
||||
placeholder="role id, role id"
|
||||
onChange={(e) => setDraft({ ...draft, staffRoles: e.target.value })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>
|
||||
Roles that can see and join every Team’s channel. Guild administrators already can, so this
|
||||
is for staff who are not administrators. Leave empty if there are none.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={runPass}>Sync now</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.rows.length > 0 && (
|
||||
<div className="panel-flat" style={{ marginTop: 18, overflowX: 'auto' }}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Team</th>
|
||||
<th className="adm-th">Members</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.rows.map((row) => (
|
||||
<tr key={row.teamId}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.teamName}</td>
|
||||
<td className="adm-td">{row.memberCount}</td>
|
||||
<td className="adm-td dim">
|
||||
{row.channelRef || <span className="dim">none</span>}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
{stateLabel(row.state)}
|
||||
{removalCountdown(row) && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
|
||||
{removalCountdown(row)}
|
||||
</span>
|
||||
)}
|
||||
{row.lastError && (
|
||||
<span style={{ display: 'block', color: '#d98b84', fontSize: '0.78rem', marginTop: 3 }}>
|
||||
{row.lastError}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => remove(row)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.lastPass && config.lastPass.at && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', marginTop: 10 }}>
|
||||
Last pass {new Date(config.lastPass.at).toLocaleString()}
|
||||
{config.lastPass.ran ? '' : ` — nothing was done: ${config.lastPass.reason}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
490
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
490
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import {
|
||||
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
|
||||
} from '../../../lib/teamAdmin.js'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import TeamIntegrations from './TeamIntegrations.jsx'
|
||||
import TeamVoice from './TeamVoice.jsx'
|
||||
|
||||
// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
|
||||
//
|
||||
// Three panels, in the order an operator needs them:
|
||||
//
|
||||
// 1. **Sync state**, verbatim, including the last error. The screen's first job
|
||||
// is to make "the shard has no Teams" and "core has not been able to ask for
|
||||
// two hours" impossible to confuse — they render almost identically
|
||||
// otherwise, and one is fine while the other is an outage.
|
||||
// 2. **The review queue** — Teams auto-hidden because their name matched the
|
||||
// impersonation list, each showing which term matched.
|
||||
// 3. **The approval queue** — what moderators have asked to publish.
|
||||
//
|
||||
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
|
||||
// plain JS and has tests; this file renders it.
|
||||
|
||||
// Tones map onto the badge modifiers the rest of the admin panel already uses,
|
||||
// rather than onto inline colours. `.badge` on its own carries no border or
|
||||
// background — those live on the modifier — so a bare `className="badge"` with an
|
||||
// inline `borderColor` renders borderless, which is what this screen used to do.
|
||||
const TONE_BADGE = { ok: 'badge-pub', warn: 'badge-moderator', bad: 'badge-ban', idle: 'badge-draft' }
|
||||
|
||||
// The same three tones as text, for the places a badge would be wrong (a verbatim
|
||||
// error line). House palette — the values every other admin view uses.
|
||||
const TONE_TEXT = { ok: '#7fd0a4', warn: '#e0b070', bad: '#d98b84', idle: 'var(--muted)' }
|
||||
|
||||
const PANEL = { padding: 22, marginBottom: 22 }
|
||||
const HEADING = { margin: '0 0 12px', fontSize: '1.2rem', color: 'var(--head)' }
|
||||
const KV_VALUE = { margin: 0, fontSize: '0.88rem', color: 'var(--text)' }
|
||||
const SCROLLER = { overflowX: 'auto' }
|
||||
const BLURB = { margin: '0 0 14px', color: 'var(--muted)', fontSize: '0.85rem', lineHeight: 1.6 }
|
||||
|
||||
function Pill({ tone, children }) {
|
||||
return <span className={`badge ${TONE_BADGE[tone] || 'badge-draft'}`}>{children}</span>
|
||||
}
|
||||
|
||||
// ── Sync state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SyncPanel({ sync, syncState, onResync, busy }) {
|
||||
const freshness = freshnessOf(sync)
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Sync</h2>
|
||||
<Pill tone={freshness.tone}>{freshness.label}</Pill>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sq"
|
||||
onClick={onResync}
|
||||
disabled={busy || !sync.configured}
|
||||
>
|
||||
{busy ? 'Resyncing…' : 'Resync now'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.85rem' }}>{freshness.detail}</p>
|
||||
|
||||
{syncState && (
|
||||
<dl
|
||||
style={{
|
||||
display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', gap: '9px 20px',
|
||||
margin: '16px 0 0', alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Module</dt>
|
||||
<dd className="sans" style={KV_VALUE}>{syncState.moduleId}</dd>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Last attempt</dt>
|
||||
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Last success</dt>
|
||||
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Consecutive failures</dt>
|
||||
<dd className="sans" style={KV_VALUE}>{syncState.consecutiveFailures}</dd>
|
||||
{syncState.lastError && (
|
||||
<>
|
||||
{/* Verbatim. An operator debugging a stale projection needs what the
|
||||
provider actually said, not a friendlier paraphrase of it. */}
|
||||
<dt className="field-label" style={{ margin: 0 }}>Last error</dt>
|
||||
<dd className="sans" style={{ ...KV_VALUE, color: TONE_TEXT.bad }}>{syncState.lastError}</dd>
|
||||
</>
|
||||
)}
|
||||
{syncState.pendingEmptySince && (
|
||||
<>
|
||||
<dt className="field-label" style={{ margin: 0 }}>Empty answer held</dt>
|
||||
<dd className="sans" style={KV_VALUE}>
|
||||
since {dateTime(syncState.pendingEmptySince)} — an authoritative but empty list is
|
||||
applied only if the next answer agrees.
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The reserved-name review queue ─────────────────────────────────────────
|
||||
|
||||
function ReviewQueue({ rows, role, onAct, busy }) {
|
||||
if (!rows.length) return null
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Names to review</h2>
|
||||
<p className="sans" style={BLURB}>
|
||||
These Teams are hidden from every public surface because their name matched a reserved term.
|
||||
They work normally for their own members. {GATED_NOTE}
|
||||
</p>
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Matched</th>
|
||||
<th className="adm-th">Members</th>
|
||||
<th className="adm-th">Created</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.name}</td>
|
||||
<td className="adm-td"><Pill tone="bad">{row.hidden_term}</Pill></td>
|
||||
<td className="adm-td">{row.member_count}</td>
|
||||
<td className="adm-td dim">{dateTime(row.created_at)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => onAct(row.id, 'unhide')}
|
||||
>
|
||||
{gateLabelFor(role, 'Publish')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The approval queue ─────────────────────────────────────────────────────
|
||||
|
||||
function RequestQueue({ rows, role, onDecide, busy }) {
|
||||
if (!rows.length) return null
|
||||
const canDecide = role === 'admin'
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>Awaiting approval</h2>
|
||||
<p className="sans" style={BLURB}>
|
||||
{canDecide
|
||||
? 'Approving publishes the name; rejecting keeps the record and changes nothing.'
|
||||
: 'Only an admin can decide these. Your own requests stay here until one does.'}
|
||||
</p>
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Request</th>
|
||||
<th className="adm-th">Requested</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
{canDecide && <th className="adm-th" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{describeRequest(row)}</td>
|
||||
<td className="adm-td dim">{dateTime(row.requested_at)}</td>
|
||||
<td className="adm-td dim">{row.reason ? `“${row.reason}”` : '—'}</td>
|
||||
{canDecide && (
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(row.id, 'approved')}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sq"
|
||||
style={{ marginLeft: 8 }}
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(row.id, 'rejected')}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── One Team ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TeamRow({ team, role, onAct, busy, onLedger }) {
|
||||
const status = statusOf(team)
|
||||
return (
|
||||
<tr>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||
{team.displayName}
|
||||
{team.displayNameOverride && (
|
||||
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||
shown instead of “{team.name}”
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td"><Pill tone={status.tone}>{status.label}</Pill></td>
|
||||
<td className="adm-td">{team.memberCount}</td>
|
||||
<td className="adm-td">{team.linkedCount}</td>
|
||||
<td className="adm-td">{team.onlineCount}</td>
|
||||
<td className="adm-td dim">{dateTime(team.rosterSyncedAt) || 'never'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{team.status === 'active' && (team.hidden
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => onAct(team.id, 'unhide')}
|
||||
>
|
||||
{gateLabelFor(role, 'Publish')}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => onAct(team.id, 'hide')}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sq"
|
||||
onClick={() => onLedger(team)}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
Forum log
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One Team's forum moderation ledger (TEAMS.md §5.3).
|
||||
*
|
||||
* The route and the API method have existed since phase 4 and nothing rendered
|
||||
* them, which made the ledger a table only a DB client could read. The column
|
||||
* that earns the screen is `actorRole`: it records WHICH authority was exercised,
|
||||
* so a leader's ordinary housekeeping stays distinguishable from a staff
|
||||
* intervention after the fact.
|
||||
*
|
||||
* **This is deliberately not merged with the site's mod_actions/appeals pair.**
|
||||
* That one is Discord-sanction-shaped and bot-owned; routing a guild leader
|
||||
* locking a thread through it would make ordinary housekeeping an appealable
|
||||
* sanction with a reversal path into the bot. Every STAFF-exercised action here
|
||||
* additionally writes activity_log, so the site's accountability trail sees it —
|
||||
* the two are cross-referenced, not merged.
|
||||
*/
|
||||
function ForumLedger({ team, onClose }) {
|
||||
const [rows, setRows] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.admin.teamForumModeration(team.id)
|
||||
// `{ entries }`, and the rows are the ledger table's own snake_case
|
||||
// columns — this endpoint serves them unmapped, unlike the Team payloads
|
||||
// above it. Reading them as they are, rather than accepting three possible
|
||||
// shapes, is what makes a change to that endpoint fail here instead of
|
||||
// rendering an empty table.
|
||||
.then((res) => { if (active) setRows(res.entries) })
|
||||
.catch((err) => { if (active) setError(err.message || 'Could not load the forum log.') })
|
||||
return () => { active = false }
|
||||
}, [team.id])
|
||||
|
||||
return (
|
||||
<section className="panel" style={PANEL}>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
gap: 14, flexWrap: 'wrap', marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Forum log — {team.displayName}</h2>
|
||||
<button type="button" className="btn btn-ghost btn-sq" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
{error && <ErrorState message={error} />}
|
||||
{!rows && !error && <Loading />}
|
||||
{rows && rows.length === 0 && (
|
||||
<p className="sans" style={{ ...BLURB, margin: 0 }}>Nothing has been moderated in this forum.</p>
|
||||
)}
|
||||
{rows && rows.length > 0 && (
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">When</th>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">Target</th>
|
||||
<th className="adm-th">By</th>
|
||||
<th className="adm-th">As</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td dim">{dateTime(r.created_at)}</td>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
|
||||
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
|
||||
<td className="adm-td">{r.actor_username || '—'}</td>
|
||||
<td className="adm-td">
|
||||
{/* The distinction the whole ledger exists to preserve. */}
|
||||
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
|
||||
</td>
|
||||
<td className="adm-td dim">{r.reason || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TeamsAdmin() {
|
||||
const { user } = useAuth()
|
||||
const role = user ? user.role : null
|
||||
|
||||
const [data, setData] = useState(null)
|
||||
const [review, setReview] = useState([])
|
||||
const [requests, setRequests] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [ledgerTeam, setLedgerTeam] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [teams, reviewQueue, requestQueue] = await Promise.all([
|
||||
api.admin.listTeams(),
|
||||
api.admin.teamReviewQueue(),
|
||||
api.admin.teamRequests('pending'),
|
||||
])
|
||||
setData(teams)
|
||||
setReview(reviewQueue.teams || [])
|
||||
setRequests(requestQueue.requests || [])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load Teams.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function run(fn, pendingMessage) {
|
||||
setBusy(true)
|
||||
setNotice('')
|
||||
setError('')
|
||||
try {
|
||||
const result = await fn()
|
||||
// The server decides whether an action applied or was filed, from the
|
||||
// caller's live role. Saying so plainly is what stops a moderator thinking
|
||||
// nothing happened.
|
||||
if (result && result.pending) setNotice(pendingMessage)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const act = (id, action) => run(
|
||||
() => (action === 'hide' ? api.admin.hideTeam(id) : api.admin.unhideTeam(id)),
|
||||
'Filed for approval. Nothing has changed publicly until an admin approves it.',
|
||||
)
|
||||
|
||||
const decide = (id, status) => run(
|
||||
() => api.admin.decideTeamRequest(id, status),
|
||||
'',
|
||||
)
|
||||
|
||||
const resync = () => run(async () => {
|
||||
const result = await api.admin.resyncTeams()
|
||||
// A refusal is the normal, designed outcome when the provider cannot answer,
|
||||
// so it is reported as a result rather than thrown as an error.
|
||||
if (!result.ok) setError(`Resync refused: ${result.reason}. Nothing was changed.`)
|
||||
else if (result.quarantined) {
|
||||
setNotice('The provider answered with an empty list. It is being held for confirmation, not applied.')
|
||||
}
|
||||
return null
|
||||
}, '')
|
||||
|
||||
if (error && !data) return <ErrorState message={error} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* No page <h1>: AdminLayout's topbar already titles the page, as it does for
|
||||
every other admin screen. This one used to render its own, which is why
|
||||
"Teams" appeared twice — once in Cinzel in the bar and once in the body
|
||||
in whatever the UA picked for an unstyled heading. */}
|
||||
{error && <ErrorState message={error} />}
|
||||
{notice && (
|
||||
<div className="note sans" style={{ fontSize: '0.85rem', marginBottom: 22 }}>{notice}</div>
|
||||
)}
|
||||
|
||||
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
|
||||
|
||||
{/* Admin-only, matching the server (§7.2). Rendered for a moderator it would
|
||||
be a panel every action in fails 403 — the role gate is the server's, and
|
||||
this is only how the screen agrees with it. */}
|
||||
{role === 'admin' && <TeamIntegrations />}
|
||||
{role === 'admin' && <TeamVoice />}
|
||||
|
||||
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
|
||||
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
||||
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
||||
|
||||
<section className="panel" style={PANEL}>
|
||||
<h2 className="display" style={HEADING}>All Teams</h2>
|
||||
{!data.teams.length && (
|
||||
<p className="sans" style={{ ...BLURB, margin: 0 }}>
|
||||
{data.configured
|
||||
? 'No Teams in the projection yet.'
|
||||
: 'No installed module supplies Teams, so there is nothing to show.'}
|
||||
</p>
|
||||
)}
|
||||
{data.teams.length > 0 && (
|
||||
<div className="panel-flat" style={SCROLLER}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Members</th>
|
||||
<th className="adm-th">Linked</th>
|
||||
<th className="adm-th">Online</th>
|
||||
<th className="adm-th">Roster confirmed</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.teams.map((team) => (
|
||||
<TeamRow
|
||||
key={team.id}
|
||||
team={team}
|
||||
role={role}
|
||||
onAct={act}
|
||||
busy={busy}
|
||||
onLedger={setLedgerTeam}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { leadershipOf }
|
||||
@@ -4,6 +4,7 @@ import { Loading, ErrorState } from '../../components/PageState.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 { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
@@ -21,7 +22,7 @@ function ChangeUsername({ account, onChanged }) {
|
||||
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
const { username: next } = await api.player.changeUsername(username.trim())
|
||||
const { username: next } = await api.changeUsername(username.trim())
|
||||
setMsg('Username updated.')
|
||||
await onChanged(next)
|
||||
} catch (err) {
|
||||
@@ -67,7 +68,7 @@ function ChangePassword({ account }) {
|
||||
if (hasPassword && !current) return setError('Enter your current password.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.player.changePassword(next, hasPassword ? current : undefined)
|
||||
await api.changePassword(next, hasPassword ? current : undefined)
|
||||
setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.')
|
||||
setCurrent('')
|
||||
setNext('')
|
||||
@@ -124,7 +125,7 @@ function TwoFactor({ account, reload }) {
|
||||
async function begin() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
setSetup(await api.player.totpSetup())
|
||||
setSetup(await api.totpSetup())
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not start setup.')
|
||||
@@ -135,7 +136,7 @@ function TwoFactor({ account, reload }) {
|
||||
async function confirm() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
const res = await api.player.totpEnable(code.trim())
|
||||
const res = await api.totpEnable(code.trim())
|
||||
setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
@@ -147,7 +148,7 @@ function TwoFactor({ account, reload }) {
|
||||
async function disable() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.player.totpDisable(code.trim())
|
||||
await api.totpDisable(code.trim())
|
||||
setCode(''); setMsg('Two-factor has been disabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
@@ -234,7 +235,7 @@ function LinkedAccounts() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.player.linkedIdentities(),
|
||||
api.myIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
@@ -251,7 +252,7 @@ function LinkedAccounts() {
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
try {
|
||||
await api.player.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -397,7 +398,7 @@ export default function PlayerAccount() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setAccount(await api.player.getAccount())
|
||||
setAccount(await api.myAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
@@ -423,6 +424,7 @@ export default function PlayerAccount() {
|
||||
{account.email ? ` · ${account.email}` : ''}
|
||||
</p>
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<EmailAddressPanel account={account} reload={load} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
{account.totp_enabled && (
|
||||
|
||||
264
client/src/routes/player/PlayerInbox.jsx
Normal file
264
client/src/routes/player/PlayerInbox.jsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { notificationSettingsPath, inboxPath } from '../../lib/notificationPaths.js'
|
||||
|
||||
// The in-app inbox (ENGAGEMENT.md Phase 7), at `/account/notifications`.
|
||||
//
|
||||
// **It took that path from the preferences screen, which moved to
|
||||
// `/account/notifications/settings`.** The two are different kinds of thing —
|
||||
// one is content addressed to this person, the other is how they would like to
|
||||
// be reached — and the word "notifications" belongs to the first: it is what a
|
||||
// person means when they say it, and what the bell in the header opens. The
|
||||
// server's routes make the same split at the same place.
|
||||
//
|
||||
// Everything a row can carry is TEXT. `body` is stored as the text part of the
|
||||
// in-app template's blocks and rendered with `white-space: pre-line`, never as
|
||||
// markup; `url` is site-relative by the time it is stored, checked against the
|
||||
// same character class `pageUrlTemplate` uses. So there is no sanitizing to do
|
||||
// here — there is nothing on this screen that could be markup.
|
||||
|
||||
const PAGE = 30
|
||||
|
||||
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 'just now'
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)} min ago`
|
||||
if (secs < 86400) return `${Math.floor(secs / 3600)} h ago`
|
||||
if (secs < 30 * 86400) return `${Math.floor(secs / 86400)} d ago`
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
|
||||
function Item({ item, onOpen, onMark }) {
|
||||
const body = (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.95rem',
|
||||
color: item.read ? 'var(--muted)' : 'var(--head)',
|
||||
fontWeight: item.read ? 500 : 700,
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</strong>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>{ago(item.createdAt)}</span>
|
||||
</div>
|
||||
{item.body && (
|
||||
<p
|
||||
className="sans dim"
|
||||
style={{ margin: '6px 0 0', fontSize: '0.86rem', whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{item.body}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
padding: '14px 16px',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
border: '1px solid var(--line-soft)',
|
||||
// The one visual difference between read and unread, plus the weight
|
||||
// above. A dot alone is easy to miss on a long list.
|
||||
background: item.read ? 'transparent' : 'var(--panel)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{item.url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(item)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</div>
|
||||
{!item.read && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMark(item)}
|
||||
className="sans"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.78rem',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Mark read
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlayerInbox() {
|
||||
const [items, setItems] = useState([])
|
||||
const [unread, setUnread] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [unreadOnly, setUnreadOnly] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
|
||||
const load = useCallback(async (only) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.notifications({ limit: PAGE, unread: only })
|
||||
setItems(res.items || [])
|
||||
setHasMore(!!res.hasMore)
|
||||
setUnread(res.unread || 0)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load your notifications')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load(unreadOnly) }, [load, unreadOnly])
|
||||
|
||||
// The cursor is the last item's id, not a page number: the list gains rows at
|
||||
// the top while it is being read, and an offset under those conditions repeats
|
||||
// or skips items.
|
||||
const more = async () => {
|
||||
if (!items.length) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.notifications({
|
||||
limit: PAGE,
|
||||
before: items[items.length - 1].id,
|
||||
unread: unreadOnly,
|
||||
})
|
||||
setItems((list) => [...list, ...(res.items || [])])
|
||||
setHasMore(!!res.hasMore)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load more')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const mark = async (item) => {
|
||||
try {
|
||||
const res = await api.markNotificationRead(item.id)
|
||||
setUnread(res.unread ?? Math.max(0, unread - 1))
|
||||
// Filtered to unread, a marked item leaves the list; unfiltered it stays
|
||||
// and goes quiet. Either way the list matches what it says it is showing.
|
||||
setItems((list) =>
|
||||
unreadOnly
|
||||
? list.filter((i) => i.id !== item.id)
|
||||
: list.map((i) => (i.id === item.id ? { ...i, read: true } : i)),
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not mark it read')
|
||||
}
|
||||
}
|
||||
|
||||
const open = async (item) => {
|
||||
if (!item.read) await mark(item)
|
||||
if (item.url) navigate(item.url)
|
||||
}
|
||||
|
||||
const markAll = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.markAllNotificationsRead()
|
||||
setUnread(0)
|
||||
setItems((list) => (unreadOnly ? [] : list.map((i) => ({ ...i, read: true }))))
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not mark them read')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading label="Loading your notifications…" />
|
||||
if (error && !items.length) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
{unread > 0 ? `${unread} unread` : 'Everything is read.'}{' '}
|
||||
<Link to={notificationSettingsPath(user)} className="dim">
|
||||
Notification settings
|
||||
</Link>
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
onClick={() => setUnreadOnly((v) => !v)}
|
||||
style={unreadOnly ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}}
|
||||
>
|
||||
{unreadOnly ? 'Showing unread' : 'Show unread only'}
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={markAll} disabled={busy || unread === 0}>
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
|
||||
{unreadOnly
|
||||
? 'Nothing unread.'
|
||||
: 'Nothing here yet. Anything the shard or your guilds want to tell you will show up on this page.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{items.map((item) => (
|
||||
<Item key={item.id} item={item} onOpen={open} onMark={mark} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<button type="button" className="pill" onClick={more} disabled={busy} style={{ marginTop: 16 }}>
|
||||
{busy ? 'Loading…' : 'Load older'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
369
client/src/routes/player/PlayerNotifications.jsx
Normal file
369
client/src/routes/player/PlayerNotifications.jsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { inboxPath } from '../../lib/notificationPaths.js'
|
||||
|
||||
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6; the
|
||||
// per-channel matrix is ENGAGEMENT.md Phase 3, surfaced in Phase 7).
|
||||
//
|
||||
// **It moved to `/account/notifications/settings` in Phase 7**, because the
|
||||
// inbox took the plain path. See `PlayerInbox.jsx`.
|
||||
//
|
||||
// **This screen did not exist before phase 6, and that was the phase's first
|
||||
// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
|
||||
// notification settings screen" — there was no such screen on the web. The stream
|
||||
// catalog and the per-stream subscriptions have been built and shipped since M7,
|
||||
// with the Android app as their only consumer; a browser could not see them at
|
||||
// all. That is tolerable for push, which needs the app anyway. It is not tolerable
|
||||
// for email, whose whole reason for existing (§6.4) is the web-only user who runs
|
||||
// neither the app nor Discord — so the sink and the screen to configure it had to
|
||||
// arrive together.
|
||||
//
|
||||
// Three blocks, in the order a user actually reasons about them: what kinds of
|
||||
// thing to be told about, then which Teams, then whether any of it should reach a
|
||||
// mailbox.
|
||||
|
||||
// The three modes a per-channel preference can take, labelled for a person. The
|
||||
// set a given channel actually offers comes from its `supportsDigest` flag.
|
||||
const MODES = [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'instant', label: 'As it happens' },
|
||||
{ value: 'digest', label: 'Daily digest' },
|
||||
]
|
||||
|
||||
const EMAIL_MODES = [
|
||||
{ value: 'off', label: 'No email' },
|
||||
{ value: 'digest', label: 'Daily digest' },
|
||||
{ value: 'immediate', label: 'Every post' },
|
||||
]
|
||||
|
||||
// Streams whose scoping lives in this page's second block rather than in the
|
||||
// first. Shown as a group so a user does not toggle `team.forum.post` off site-
|
||||
// wide when what they meant was "not this one guild".
|
||||
const isTeamStream = (id) => String(id).startsWith('team.')
|
||||
|
||||
function Section({ title, hint, children }) {
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
|
||||
{hint && <p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.86rem' }}>{hint}</p>}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Note({ msg, error }) {
|
||||
if (!msg && !error) return null
|
||||
return (
|
||||
<p className="sans" style={{ margin: '10px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>
|
||||
{error || msg}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ── What to be told about, and how ─────────────────────────────────────────
|
||||
//
|
||||
// **This replaced the push-only checkbox list, and it is a strict superset of
|
||||
// it.** `GET /auth/me/notifications/channels` returns every subscribable id —
|
||||
// every push stream and every event trigger, one namespace (§7.2) — with the
|
||||
// EFFECTIVE mode on each channel that applies. A trigger with nothing
|
||||
// registered to push it simply has no push cell; core does not have to explain
|
||||
// which kind of id a row is, and neither does a reader.
|
||||
//
|
||||
// The old whole-set endpoints are untouched and are now this surface's push
|
||||
// projection: the shipped Android app keeps its wire shape, and a `push` entry
|
||||
// written here is mirrored back into `notification_subscriptions` server-side.
|
||||
//
|
||||
// The update is SPARSE: only the cells that changed are sent. That is what lets
|
||||
// this screen manage three channels without a whole-set PUT that could clobber
|
||||
// a preference a newer client set.
|
||||
|
||||
function Channels({ channels, items, onSave, busy, msg, error }) {
|
||||
const [edits, setEdits] = useState({})
|
||||
useEffect(() => setEdits({}), [items])
|
||||
|
||||
const key = (id, channel) => `${id}|${channel}`
|
||||
const modeOf = (item, channel) => edits[key(item.id, channel)] ?? item.modes[channel]
|
||||
const set = (id, channel, mode) => setEdits((e) => ({ ...e, [key(id, channel)]: mode }))
|
||||
|
||||
// A channel that supports digest offers three modes; one that does not offers
|
||||
// two. Read off the registry rather than hardcoded, so a channel added later
|
||||
// shows the right options without touching this file.
|
||||
const modesFor = (c) => (c.supportsDigest ? MODES : MODES.filter((m) => m.value !== 'digest'))
|
||||
|
||||
const changed = Object.entries(edits).filter(([k, mode]) => {
|
||||
const [id, channel] = k.split('|')
|
||||
const item = items.find((i) => i.id === id)
|
||||
return item && item.modes[channel] !== mode
|
||||
})
|
||||
|
||||
const save = () =>
|
||||
onSave(
|
||||
changed.map(([k, mode]) => {
|
||||
const [id, channel] = k.split('|')
|
||||
return { id, channel, mode }
|
||||
}),
|
||||
)
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Section title="What to notify me about">
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
|
||||
There is nothing to configure yet.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
const team = items.filter((i) => isTeamStream(i.id))
|
||||
const rest = items.filter((i) => !isTeamStream(i.id))
|
||||
|
||||
const rows = (list) =>
|
||||
list.map((item) => (
|
||||
<tr key={item.id} style={{ borderTop: '1px solid var(--line-soft)' }}>
|
||||
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
|
||||
{item.label}
|
||||
{item.description && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.8rem' }}>{item.description}</span>
|
||||
)}
|
||||
</td>
|
||||
{channels.map((c) => (
|
||||
<td key={c.id} style={{ padding: '10px' }}>
|
||||
{item.channels.includes(c.id) ? (
|
||||
<select
|
||||
className="input"
|
||||
aria-label={`${item.label} — ${c.label}`}
|
||||
value={modeOf(item, c.id)}
|
||||
onChange={(e) => set(item.id, c.id, e.target.value)}
|
||||
style={{ fontSize: '0.86rem' }}
|
||||
>
|
||||
{modesFor(c).map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
// Not "off" — a dash. Nothing is registered to push this id, so
|
||||
// there is no preference to hold, and an `off` select would invite
|
||||
// somebody to switch on a channel that has no sender behind it.
|
||||
<span className="dim" style={{ fontSize: '0.86rem' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="What to notify me about"
|
||||
hint="Applies to every device you have signed in on. On the site means an item in your notification inbox; push wakes the app, which then fetches the content."
|
||||
>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Notification</th>
|
||||
{channels.map((c) => (
|
||||
<th key={c.id} style={{ padding: '8px 10px' }} title={c.description || undefined}>{c.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows(rest)}
|
||||
{team.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={channels.length + 1} className="sans dim" style={{ padding: '18px 10px 6px', fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
Teams — set site-wide here, then per team below
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows(team)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy || changed.length === 0} onClick={save}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Which Teams, and whether by email ──────────────────────────────────────
|
||||
|
||||
function Teams({ teams, onSave, busy, msg, error }) {
|
||||
const [rows, setRows] = useState(teams)
|
||||
useEffect(() => { setRows(teams) }, [teams])
|
||||
|
||||
const patch = (teamId, change) =>
|
||||
setRows((rs) => rs.map((r) => (r.teamId === teamId ? { ...r, ...change } : r)))
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Section title="Teams">
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
|
||||
You are not in a team, and nobody has given you access to a team forum. There is nothing to
|
||||
configure here yet.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Teams"
|
||||
hint="Muting a team silences all four team notifications for it, without changing anything for your other teams. Email is off until you turn it on."
|
||||
>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Team</th>
|
||||
<th style={{ padding: '8px 10px' }}>Notifications</th>
|
||||
<th style={{ padding: '8px 10px' }}>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.teamId} style={{ borderTop: '1px solid var(--line-soft)' }}>
|
||||
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
|
||||
{t.name}
|
||||
{/* An archived Team is still listed when a preference exists for
|
||||
it, so a mute does not silently vanish when a guild disbands
|
||||
and reappear if it re-forms under the same name. */}
|
||||
{t.archived && <span className="dim" style={{ fontSize: '0.78rem' }}> · archived</span>}
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: '0.88rem' }}>
|
||||
<input type="checkbox" checked={!t.muted} onChange={() => patch(t.teamId, { muted: !t.muted })} />
|
||||
<span className="dim">{t.muted ? 'Muted' : 'On'}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={t.emailMode}
|
||||
onChange={(e) => patch(t.teamId, { emailMode: e.target.value })}
|
||||
style={{ fontSize: '0.88rem' }}
|
||||
>
|
||||
{EMAIL_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave(rows)}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PlayerNotifications() {
|
||||
const { user } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [channels, setChannels] = useState([])
|
||||
const [items, setItems] = useState([])
|
||||
const [teams, setTeams] = useState([])
|
||||
const [saving, setSaving] = useState({ channels: false, teams: false })
|
||||
const [notes, setNotes] = useState({ channels: '', teams: '', channelsError: '', teamsError: '' })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Two reads in parallel, where there used to be three: the per-channel
|
||||
// surface already carries the catalog and this user's effective modes, so
|
||||
// the streams+subscriptions pair it replaced is one request fewer as well
|
||||
// as one concept fewer.
|
||||
const [prefs, teamPrefs] = await Promise.all([
|
||||
api.notificationChannelPrefs(),
|
||||
api.teamNotificationPrefs(),
|
||||
])
|
||||
setChannels(prefs.channels || [])
|
||||
setItems(prefs.items || [])
|
||||
setTeams(teamPrefs.teams || [])
|
||||
setError('')
|
||||
} catch {
|
||||
setError('Could not load your notification settings.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const saveChannels = useCallback(async (prefs) => {
|
||||
if (prefs.length === 0) return
|
||||
setSaving((s) => ({ ...s, channels: true }))
|
||||
setNotes((n) => ({ ...n, channels: '', channelsError: '' }))
|
||||
try {
|
||||
// The endpoint echoes the FULL stored state back, not just what was sent —
|
||||
// so an entry it dropped (an unknown id, a channel that does not apply, a
|
||||
// mode that channel will not take) is visible here as a cell that did not
|
||||
// move, rather than as a screen that claims a save it did not make.
|
||||
const stored = await api.setNotificationChannelPrefs(prefs)
|
||||
setChannels(stored.channels || [])
|
||||
setItems(stored.items || [])
|
||||
setNotes((n) => ({ ...n, channels: 'Saved.' }))
|
||||
} catch {
|
||||
setNotes((n) => ({ ...n, channelsError: 'Could not save that.' }))
|
||||
} finally {
|
||||
setSaving((s) => ({ ...s, channels: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveTeams = useCallback(async (rows) => {
|
||||
setSaving((s) => ({ ...s, teams: true }))
|
||||
setNotes((n) => ({ ...n, teams: '', teamsError: '' }))
|
||||
try {
|
||||
// The whole set, every time, and the array is sent even when empty — the
|
||||
// endpoint requires the field (docs/android/PLAN.md §11).
|
||||
const { teams: stored } = await api.setTeamNotificationPrefs(
|
||||
rows.map((t) => ({ teamId: t.teamId, muted: t.muted, emailMode: t.emailMode })),
|
||||
)
|
||||
setTeams(stored || [])
|
||||
setNotes((n) => ({ ...n, teams: 'Saved.' }))
|
||||
} catch {
|
||||
setNotes((n) => ({ ...n, teamsError: 'Could not save that.' }))
|
||||
} finally {
|
||||
setSaving((s) => ({ ...s, teams: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Choose what you are told about, and how. Email and push are off until you switch them on;
|
||||
items on the site go to your <Link to={inboxPath(user)}>notification inbox</Link>,
|
||||
which you can turn off here per notification.
|
||||
</p>
|
||||
<Channels
|
||||
channels={channels}
|
||||
items={items}
|
||||
onSave={saveChannels}
|
||||
busy={saving.channels}
|
||||
msg={notes.channels}
|
||||
error={notes.channelsError}
|
||||
/>
|
||||
<Teams
|
||||
teams={teams}
|
||||
onSave={saveTeams}
|
||||
busy={saving.teams}
|
||||
msg={notes.teams}
|
||||
error={notes.teamsError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
||||
import { NavLink, Navigate, 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'
|
||||
@@ -35,6 +36,10 @@ function Icon({ children, size = 16 }) {
|
||||
}
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></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>
|
||||
// The settings row's own icon: a bell would make the two rows read as the same
|
||||
// destination twice, which is exactly the confusion the split was meant to end.
|
||||
const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" /><circle cx="18" cy="18" r="3" /><path d="M18 14v1M18 21v1M14 18h1M21 18h1" /></Icon>
|
||||
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
|
||||
@@ -47,6 +52,8 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
|
||||
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
]
|
||||
|
||||
@@ -56,6 +63,8 @@ export const NAV = [
|
||||
const TITLES = {
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
'/account/notifications': 'Notifications',
|
||||
'/account/notifications/settings': 'Notification settings',
|
||||
}
|
||||
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
@@ -183,9 +192,12 @@ export default function PlayerPortalLayout() {
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h1>
|
||||
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
|
||||
← Site
|
||||
</a>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<NotificationBell />
|
||||
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
|
||||
← Site
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}>
|
||||
|
||||
69
client/src/routes/player/Unsubscribe.jsx
Normal file
69
client/src/routes/player/Unsubscribe.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The landing page for the unsubscribe link in a Team notification email
|
||||
// (TEAMS.md §6.4).
|
||||
//
|
||||
// **Public, and it must be**: the person reading it is in their mail client, not
|
||||
// signed in, and an unsubscribe that first demands a login is one most people do
|
||||
// not complete. The token in the path is what stands in for the session.
|
||||
//
|
||||
// **The page POSTs; the link the user clicked was a GET.** A GET must not mutate —
|
||||
// mail clients and security scanners follow links in messages, and one that did
|
||||
// would silently mute Teams nobody asked to leave. So the link lands here, this
|
||||
// runs one POST, and the API route that shares the path answers GET with a
|
||||
// redirect to exactly this page.
|
||||
//
|
||||
// **It says the same thing whatever the token was.** A page that distinguished a
|
||||
// valid token from a forged one would be an oracle for which (user, Team) pairs
|
||||
// exist, on a surface with no session behind it. The server always answers 200 and
|
||||
// this always says the same sentence.
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const { token } = useParams()
|
||||
const [state, setState] = useState('working')
|
||||
// React 18 StrictMode mounts an effect twice in development. The POST is
|
||||
// idempotent (it sets a boolean), so a second call is harmless — but it is
|
||||
// still a second request for no reason, and the guard keeps the network panel
|
||||
// honest for anyone debugging this page.
|
||||
const fired = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (fired.current) return
|
||||
fired.current = true
|
||||
api.unsubscribeTeam(token)
|
||||
.then(() => setState('done'))
|
||||
// A network failure is the ONE case worth distinguishing, because it is the
|
||||
// one where trying again helps. A rejected token is not: the server does not
|
||||
// tell us, deliberately.
|
||||
.catch(() => setState('failed'))
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website" shell="narrow">
|
||||
<PageHeader eyebrow="Notifications" title="Unsubscribe" />
|
||||
{state === 'working' && <p className="sans dim">One moment…</p>}
|
||||
{state === 'done' && (
|
||||
<>
|
||||
<p className="sans" style={{ color: 'var(--ink)' }}>
|
||||
You will not receive further notification emails about this team.
|
||||
</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
|
||||
This muted the team rather than switching off your account’s email, so your other
|
||||
teams are unaffected. You can turn it back on any time under{' '}
|
||||
<Link to="/account/notifications/settings">notification settings</Link>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === 'failed' && (
|
||||
<p className="sans" style={{ color: 'var(--ink)' }}>
|
||||
We could not reach the site to record that. Please try the link again, or change the
|
||||
setting yourself under <Link to="/account/notifications/settings">notification settings</Link>.
|
||||
</p>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
166
client/src/routes/player/VerifyEmail.jsx
Normal file
166
client/src/routes/player/VerifyEmail.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell from './PlayerShell.jsx'
|
||||
|
||||
// Public, token-gated confirmation page (/account/verify-email/:token).
|
||||
//
|
||||
// Unauthenticated on purpose: the link arrives in a mailbox and is routinely
|
||||
// opened on a device with no session. That is safe because the token IS the
|
||||
// proof — opening it installs an address on the account it was minted for and
|
||||
// does nothing else. No session is issued here, deliberately: proving control of
|
||||
// a mailbox is not proving control of an account.
|
||||
//
|
||||
// Every failure the server can have — expired, already used, superseded by a
|
||||
// later request, or an address another account confirmed first — comes back as
|
||||
// the same 404. That is not laziness on the server's part; distinguishing them
|
||||
// would let anyone test which addresses have accounts. So this page says the same
|
||||
// thing for all of them, and must keep doing so.
|
||||
export default function VerifyEmail() {
|
||||
const { token } = useParams()
|
||||
|
||||
const [link, setLink] = useState(null) // { username, email } once validated
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api
|
||||
.lookupEmailVerification(token)
|
||||
.then((r) => active && setLink(r || {}))
|
||||
.catch(
|
||||
(err) =>
|
||||
active &&
|
||||
setLoadErr(
|
||||
err.status === 404
|
||||
? 'This confirmation link is invalid or has expired.'
|
||||
: 'Could not load this confirmation link.',
|
||||
),
|
||||
)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
async function onConfirm() {
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.confirmEmailVerification(token)
|
||||
setDone(true)
|
||||
} catch (err) {
|
||||
if (err.status === 404) setError('This confirmation link is no longer usable. Request a new one from your account page.')
|
||||
else if (err.status === 429) setError('Too many attempts. Please try again in a little while.')
|
||||
else setError('Could not confirm your address right now. Please try again later.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invalid link ───────────────────────────────────────────────────────────
|
||||
if (loadErr) {
|
||||
return (
|
||||
<PlayerShell subtitle="Confirm your email">
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{loadErr}
|
||||
</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||
<Link to="/account" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Go to your account
|
||||
</Link>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
if (link === null) {
|
||||
return (
|
||||
<PlayerShell subtitle="Confirm your email">
|
||||
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
|
||||
<span className="spin" />
|
||||
</div>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Done ───────────────────────────────────────────────────────────────────
|
||||
if (done) {
|
||||
return (
|
||||
<PlayerShell subtitle="Email confirmed">
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{link.email ? (
|
||||
<>
|
||||
<strong style={{ color: 'var(--head)' }}>{link.email}</strong> is now the address for
|
||||
{link.username ? (
|
||||
<>
|
||||
{' '}
|
||||
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
|
||||
</>
|
||||
) : (
|
||||
' your account'
|
||||
)}
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
'Your email address has been confirmed.'
|
||||
)}
|
||||
</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.85rem', color: 'var(--dim)' }}>
|
||||
You have not been signed in — confirming an address does not sign you in.
|
||||
</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Confirm ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A button rather than confirming on load. A mail client or scanner that
|
||||
// pre-fetches links would otherwise spend the token before the person ever saw
|
||||
// it, and this token is single-use.
|
||||
return (
|
||||
<PlayerShell subtitle="Confirm your email">
|
||||
<p
|
||||
className="sans"
|
||||
style={{ marginTop: 0, marginBottom: 20, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}
|
||||
>
|
||||
Confirm that{' '}
|
||||
{link.email ? <strong style={{ color: 'var(--head)' }}>{link.email}</strong> : 'this address'} should be
|
||||
the contact and account-recovery address for
|
||||
{link.username ? (
|
||||
<>
|
||||
{' '}
|
||||
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
|
||||
</>
|
||||
) : (
|
||||
' this account'
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
||||
>
|
||||
{busy ? 'Confirming…' : 'Confirm this address'}
|
||||
</button>
|
||||
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.82rem', color: 'var(--dim)' }}>
|
||||
If you did not ask for this, close this page. Nothing changes and no account of yours is affected.
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
@@ -298,6 +298,18 @@ button[disabled] {
|
||||
}
|
||||
|
||||
/* ===== Rich prose (wiki / newsletter body) ===== */
|
||||
.forum-embed {
|
||||
/* The image a Team-forum post's URL renders as, in `remote`/`uploads` mode.
|
||||
Emitted by the server (utils/forumHtml.js), never by an author — which is
|
||||
what makes the operator's image policy enforceable. Block, so it sits
|
||||
beneath its link rather than beside it; capped, because a remote image is
|
||||
whatever size its host decided and one post must not blow out the column. */
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
.prose {
|
||||
color: var(--text);
|
||||
font-size: 1.06rem;
|
||||
|
||||
@@ -185,3 +185,70 @@ test('a module id is URL-encoded on the way into the path', async () => {
|
||||
await api.admin.disableModule('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
||||
})
|
||||
|
||||
// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
|
||||
//
|
||||
// The URL shapes matter more here than they look. Replies hang off a THREAD;
|
||||
// edits and post moderation hang off a POST; and the report route hangs off the
|
||||
// forum rather than off either, because a report can name a thread, a post or an
|
||||
// upload and is not moderation of any of them.
|
||||
|
||||
test('a reply hangs off its thread and an edit hangs off its post', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReply('ossuary', 5, { body: 'hi' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
|
||||
// PATCH, not POST: an edit replaces part of a post that already exists, and the
|
||||
// server's route is mounted on the verb.
|
||||
assert.equal(calls[0].opts.method, 'PATCH')
|
||||
})
|
||||
|
||||
test('post moderation is a different route from thread moderation', async () => {
|
||||
// Not the same route with a target kind, because the two answer to different
|
||||
// rules — `pin` and `lock` mean nothing to a post at all.
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumModerate('ossuary', 5, { action: 'pin' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
|
||||
})
|
||||
|
||||
test('a report goes to the forum, and its queue is under admin moderation', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), {
|
||||
targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
|
||||
})
|
||||
|
||||
// Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
|
||||
// should have one place to work, and there is deliberately no leader-facing
|
||||
// counterpart to this call anywhere in the client (TEAMS.md §5.6).
|
||||
calls = []
|
||||
willReply({ body: { reports: [] } })
|
||||
await api.admin.contentReports({ status: 'open' })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
|
||||
})
|
||||
|
||||
test('the report queue defaults to the open work rather than to everything', async () => {
|
||||
willReply({ body: { reports: [] } })
|
||||
await api.admin.contentReports()
|
||||
// No query string at all — the server's default is open + reviewing, and a
|
||||
// client that pinned `status=all` here would put the archive in front of a
|
||||
// staffer every time they opened the screen.
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
|
||||
})
|
||||
|
||||
test('a Team slug is URL-encoded on every forum path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
||||
})
|
||||
|
||||
154
client/test/emailTemplates.test.js
Normal file
154
client/test/emailTemplates.test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { test, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
RESERVED_KEYS,
|
||||
registerEmailBlock,
|
||||
getEmailBlock,
|
||||
listEmailBlocks,
|
||||
newEmailBlock,
|
||||
} from '../src/emailBlocks/registry.js'
|
||||
|
||||
// Engagement Phase 5b — the client half of the template editor.
|
||||
//
|
||||
// Two kinds of test, and the second kind is the one worth explaining.
|
||||
//
|
||||
// `registry.js` is plain `.js` and imports nothing, so it is exercised directly.
|
||||
// `types.jsx` and `EngagementTemplates.jsx` cannot be: this runner has no JSX
|
||||
// transform and no DOM, the same limit `moduleRegistry.test.js` documents. So the
|
||||
// properties that live in those files are asserted **against their source text**.
|
||||
//
|
||||
// That is a weaker test than executing them, and it is used for exactly two things
|
||||
// where a weak test still beats none:
|
||||
//
|
||||
// • **The preview sandbox.** `sandbox=""` with no `allow-scripts` is the reason
|
||||
// operator-authored HTML cannot run under this site's origin. It is one
|
||||
// attribute, on one element, and it is precisely the sort of thing someone
|
||||
// removes to debug a rendering problem and does not put back. A source
|
||||
// assertion catches that in review; nothing else here would.
|
||||
// • **Registry drift.** Every `email.*` type this client offers must exist in
|
||||
// the server registry with the same version, because the server validates
|
||||
// against its own and a drifted client produces a refused save with no
|
||||
// explanation on screen. Reading both trees is the only way to check a
|
||||
// pairing that spans a process boundary.
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const read = (rel) => fs.readFileSync(path.join(here, '..', rel), 'utf8')
|
||||
|
||||
// The registry is module state; each test starts from a known entry.
|
||||
beforeEach(() => {
|
||||
if (!getEmailBlock('email.test')) {
|
||||
registerEmailBlock({
|
||||
type: 'email.test',
|
||||
version: 2,
|
||||
label: 'Test block',
|
||||
defaults: () => ({ text: 'hi' }),
|
||||
editor: () => null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── The registry ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a definition must be namespaced "email."', () => {
|
||||
assert.throws(() => registerEmailBlock({ type: 'heading' }), /namespaced/)
|
||||
assert.throws(() => registerEmailBlock({}), /namespaced/)
|
||||
})
|
||||
|
||||
test('a duplicate type is a programmer error, caught at import', () => {
|
||||
assert.throws(() => registerEmailBlock({ type: 'email.test' }), /already registered/)
|
||||
})
|
||||
|
||||
test('a new block carries the envelope the server expects, and a unique id', () => {
|
||||
const a = newEmailBlock('email.test')
|
||||
const b = newEmailBlock('email.test')
|
||||
assert.deepEqual(Object.keys(a).sort(), [...RESERVED_KEYS].sort())
|
||||
assert.equal(a.type, 'email.test')
|
||||
assert.equal(a.version, 2)
|
||||
assert.deepEqual(a.props, { text: 'hi' })
|
||||
// Ids are unique across a whole document. A counter would re-issue an id after
|
||||
// a delete and the save would be refused for a reason nothing on screen explains.
|
||||
assert.notEqual(a.id, b.id)
|
||||
})
|
||||
|
||||
test('an unknown type yields nothing rather than a half-built block', () => {
|
||||
assert.equal(newEmailBlock('email.nope'), null)
|
||||
assert.equal(getEmailBlock('email.nope'), null)
|
||||
})
|
||||
|
||||
// ── The sandbox: §4.6.2's security posture, as an attribute ────────────────
|
||||
|
||||
test('the preview frame is sandboxed with no allow-scripts', () => {
|
||||
const source = read('src/routes/admin/views/EngagementTemplates.jsx')
|
||||
|
||||
// It renders in an iframe at all — not into the page.
|
||||
assert.match(source, /<iframe/)
|
||||
|
||||
// Read the ATTRIBUTE, not the file. The first version of this test searched the
|
||||
// whole source for "allow-scripts" and failed on the comment above the iframe
|
||||
// explaining that there is no allow-scripts — a check that a correct file fails
|
||||
// is worse than no check, because the fix is to delete the explanation.
|
||||
const sandboxes = [...source.matchAll(/sandbox=(?:"([^"]*)"|\{([^}]*)\})/g)].map((m) => m[1] ?? m[2])
|
||||
assert.equal(sandboxes.length, 1, 'expected exactly one sandboxed frame')
|
||||
// Empty: every restriction on, nothing granted back.
|
||||
assert.equal(sandboxes[0], '')
|
||||
// The two grants that would undo it, whatever else were listed.
|
||||
assert.doesNotMatch(sandboxes[0], /allow-scripts/)
|
||||
assert.doesNotMatch(sandboxes[0], /allow-same-origin/)
|
||||
|
||||
// And no iframe without one at all.
|
||||
assert.equal((source.match(/<iframe/g) || []).length, sandboxes.length)
|
||||
|
||||
// From srcDoc — an opaque origin — rather than a src pointing at this site.
|
||||
assert.match(source, /srcDoc=/)
|
||||
})
|
||||
|
||||
test('the preview HTML is never injected into this document', () => {
|
||||
const source = read('src/routes/admin/views/EngagementTemplates.jsx')
|
||||
// The one API that would undo all of the above in a single line.
|
||||
assert.doesNotMatch(source, /dangerouslySetInnerHTML/)
|
||||
})
|
||||
|
||||
// ── Drift between the two registries ───────────────────────────────────────
|
||||
|
||||
test('every client email block pairs with a server definition at the same version', () => {
|
||||
const clientSource = read('src/emailBlocks/types.jsx')
|
||||
const clientTypes = [...clientSource.matchAll(/type:\s*'(email\.[A-Za-z]+)',\s*\n\s*version:\s*(\d+)/g)].map(
|
||||
(m) => [m[1], Number(m[2])],
|
||||
)
|
||||
assert.ok(clientTypes.length >= 6, 'expected the six block definitions to be found')
|
||||
|
||||
const serverDir = path.join(here, '..', '..', 'server', 'src', 'emailBlocks', 'types')
|
||||
const serverTypes = new Map()
|
||||
for (const file of fs.readdirSync(serverDir)) {
|
||||
const src = fs.readFileSync(path.join(serverDir, file), 'utf8')
|
||||
const type = src.match(/type:\s*'(email\.[A-Za-z]+)'/)
|
||||
const version = src.match(/\n\s*version:\s*(\d+)/)
|
||||
if (type) serverTypes.set(type[1], version ? Number(version[1]) : 1)
|
||||
}
|
||||
|
||||
for (const [type, version] of clientTypes) {
|
||||
assert.ok(serverTypes.has(type), `${type} has no server definition`)
|
||||
assert.equal(serverTypes.get(type), version, `${type} version differs between client and server`)
|
||||
}
|
||||
// And the other direction: a server block with no authoring form is a block an
|
||||
// operator can be sent a template containing and cannot edit.
|
||||
for (const type of serverTypes.keys()) {
|
||||
assert.ok(
|
||||
clientTypes.some(([t]) => t === type),
|
||||
`${type} exists on the server but has no editor in this client`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('no client email block declares a React renderer', () => {
|
||||
// The structural claim in registry.js's header. A `component` here would be a
|
||||
// second renderer for a body the server produces, and the two would agree only
|
||||
// until the first Outlook fix.
|
||||
const clientSource = read('src/emailBlocks/types.jsx')
|
||||
assert.doesNotMatch(clientSource, /\n\s*component:/)
|
||||
assert.ok(listEmailBlocks().every((d) => !('component' in d)))
|
||||
})
|
||||
307
client/test/engagementRules.test.js
Normal file
307
client/test/engagementRules.test.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
formFromRule,
|
||||
ruleToPayload,
|
||||
audienceChoicesFor,
|
||||
segmentChoicesFor,
|
||||
describeReach,
|
||||
describeRule,
|
||||
describeExpression,
|
||||
notPlacementError,
|
||||
audienceWarning,
|
||||
operatorWords,
|
||||
conditionRowsFrom,
|
||||
conditionsFromRows,
|
||||
operatorsForType,
|
||||
coerceLiteral,
|
||||
humanSeconds,
|
||||
} from '../src/lib/engagementRules.js'
|
||||
|
||||
// lib/engagementRules.js — what the two Engagement screens say and what they let
|
||||
// an operator pick (ENGAGEMENT.md Phase 4b).
|
||||
//
|
||||
// None of this is a boundary: the server's `engagementRules.model` decides what
|
||||
// may be saved and the engine re-checks the audience ceiling at send time. What
|
||||
// is tested here is the part that would be wrong SILENTLY — a form that sends a
|
||||
// string where the trigger declared an int, a composer that flattens a nested
|
||||
// condition into one that fires on different events, an editor that offers an
|
||||
// audience the save is going to refuse.
|
||||
|
||||
const CEILINGS = [
|
||||
{ id: 'everyone', label: 'Everyone', permits: ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'owner'] },
|
||||
{ id: 'authenticated', label: 'Signed-in users', permits: ['authenticated', 'subscribers', 'members', 'staff', 'owner'] },
|
||||
{ id: 'subscribers', label: 'Subscribers', permits: ['subscribers'] },
|
||||
{ id: 'members', label: 'A module list', permits: ['members'] },
|
||||
{ id: 'staff', label: 'Staff', permits: ['staff'] },
|
||||
{ id: 'owner', label: 'The person it is about', permits: ['owner'] },
|
||||
]
|
||||
|
||||
const TRIGGER = {
|
||||
id: 'uo.house.idoc_warning',
|
||||
label: 'House approaching collapse',
|
||||
ceiling: 'owner',
|
||||
audience: 'owner',
|
||||
subjectKey: 'house',
|
||||
variables: [
|
||||
{ name: 'house', type: 'string', required: true },
|
||||
{ name: 'daysLeft', type: 'int', required: false },
|
||||
{ name: 'insured', type: 'boolean', required: false },
|
||||
],
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ cmp: 'eq', label: 'is', types: ['string', 'int', 'boolean'], arity: 1 },
|
||||
{ cmp: 'gt', label: 'is greater than', types: ['int'], arity: 1 },
|
||||
{ cmp: 'in', label: 'is one of', types: ['string', 'int'], arity: 'list' },
|
||||
{ cmp: 'present', label: 'is present', types: ['string', 'int', 'boolean'], arity: 0 },
|
||||
]
|
||||
|
||||
const row = (over = {}) => ({
|
||||
id: 3,
|
||||
trigger_id: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
enabled: 1,
|
||||
audience: 'owner',
|
||||
audience_segment_id: null,
|
||||
channels: ['email'],
|
||||
template_keys: { email: 'idoc-warning' },
|
||||
conditions: null,
|
||||
cooldown_seconds: 86400,
|
||||
delay_seconds: 0,
|
||||
cancel_on: [],
|
||||
max_sends_per_hour: 100,
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── The form round trip ────────────────────────────────────────────────────
|
||||
|
||||
test('a rule row round-trips through the form without changing what it means', () => {
|
||||
const payload = ruleToPayload(formFromRule(row()))
|
||||
|
||||
assert.equal(payload.triggerId, 'uo.house.idoc_warning')
|
||||
assert.equal(payload.enabled, true)
|
||||
assert.deepEqual(payload.channels, ['email'])
|
||||
assert.deepEqual(payload.templateKeys, { email: 'idoc-warning' })
|
||||
assert.equal(payload.cooldownSeconds, 86400)
|
||||
assert.equal(payload.maxSendsPerHour, 100)
|
||||
})
|
||||
|
||||
test('unticking a channel drops its template key, rather than sending one the server refuses', () => {
|
||||
const form = formFromRule(row({ channels: ['email', 'push'], template_keys: { email: 'a', push: 'b' } }))
|
||||
form.channels = ['email']
|
||||
|
||||
const payload = ruleToPayload(form)
|
||||
|
||||
// The server refuses `templateKeys` naming a channel the rule does not have.
|
||||
// Leaving it in would produce an error about a field the operator cannot see.
|
||||
assert.deepEqual(payload.templateKeys, { email: 'a' })
|
||||
})
|
||||
|
||||
// ── The audience the editor may offer ──────────────────────────────────────
|
||||
|
||||
test('the editor offers only what the trigger ceiling permits', () => {
|
||||
const choices = audienceChoicesFor(TRIGGER, CEILINGS).map((c) => c.id)
|
||||
assert.deepEqual(choices, ['owner'])
|
||||
})
|
||||
|
||||
test('a wider trigger offers more, in lattice order', () => {
|
||||
const choices = audienceChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS).map((c) => c.id)
|
||||
assert.deepEqual(choices, ['authenticated', 'subscribers', 'members', 'staff', 'owner'])
|
||||
})
|
||||
|
||||
test('an unknown trigger offers nothing — failing closed, like the server', () => {
|
||||
// This is a dormant rule, whose module has been uninstalled. Offering the full
|
||||
// vocabulary would be the widening the whole ceiling design exists to prevent.
|
||||
assert.deepEqual(audienceChoicesFor({ ...TRIGGER, ceiling: 'nonsense' }, CEILINGS), [])
|
||||
assert.deepEqual(audienceChoicesFor(null, CEILINGS), [])
|
||||
})
|
||||
|
||||
test('segments are filtered by their STORED ceiling, not re-derived', () => {
|
||||
const segments = [
|
||||
{ id: 1, name: 'Governors', ceiling: 'members' },
|
||||
{ id: 2, name: 'Watchers', ceiling: 'authenticated' },
|
||||
]
|
||||
const wide = segmentChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS, segments)
|
||||
assert.deepEqual(wide.map((s) => s.id), [1, 2])
|
||||
|
||||
const narrow = segmentChoicesFor({ ...TRIGGER, ceiling: 'members' }, CEILINGS, segments)
|
||||
assert.deepEqual(narrow.map((s) => s.id), [1])
|
||||
})
|
||||
|
||||
// ── The reach preview ──────────────────────────────────────────────────────
|
||||
|
||||
test('a capped count reads as a floor, never as a total', () => {
|
||||
const said = describeReach({ count: 5000, capped: true, dormant: false, reason: null, permitted: true })
|
||||
assert.match(said, /At least 5000/)
|
||||
})
|
||||
|
||||
test('a count the trigger would refuse says so, instead of looking healthy', () => {
|
||||
const said = describeReach({ count: 12, capped: false, dormant: false, reason: null, permitted: false })
|
||||
assert.match(said, /will be refused/)
|
||||
})
|
||||
|
||||
test('a dormant segment says why, rather than reading as "nobody"', () => {
|
||||
const said = describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' })
|
||||
assert.match(said, /dormant/)
|
||||
})
|
||||
|
||||
test('an owner audience carries its reason forward', () => {
|
||||
const said = describeReach({ count: 0, dormant: false, reason: 'event carries no ownerUserId', permitted: true })
|
||||
assert.match(said, /ownerUserId/)
|
||||
})
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('operators narrow to the variable type that was picked', () => {
|
||||
assert.deepEqual(operatorsForType(OPERATORS, 'boolean').map((o) => o.cmp), ['eq', 'present'])
|
||||
assert.deepEqual(operatorsForType(OPERATORS, 'int').map((o) => o.cmp), ['eq', 'gt', 'in', 'present'])
|
||||
})
|
||||
|
||||
test('a literal is coerced to the type the trigger DECLARED', () => {
|
||||
// Every value in an HTML input is a string, and `{ cmp: 'gt', value: "5" }`
|
||||
// against an int variable is refused by the server — rightly, because a
|
||||
// comparison between a number and a string quietly never matches.
|
||||
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'gt', value: '5' }], TRIGGER.variables)
|
||||
assert.deepEqual(built, { variable: 'daysLeft', cmp: 'gt', value: 5 })
|
||||
})
|
||||
|
||||
test('a value that does not parse is passed through, so the server names the field', () => {
|
||||
// NOT NaN, and not 0: a rule that saves cleanly having silently compared
|
||||
// against a number nobody typed is worse than a refusal that says which
|
||||
// variable it was.
|
||||
assert.equal(coerceLiteral('int', 'soon'), 'soon')
|
||||
assert.equal(coerceLiteral('boolean', 'yes'), 'yes')
|
||||
assert.equal(coerceLiteral('boolean', 'true'), true)
|
||||
assert.equal(coerceLiteral('float', '1.5'), 1.5)
|
||||
})
|
||||
|
||||
test('a list operator splits on commas and types each item', () => {
|
||||
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'in', value: '1, 2, 3' }], TRIGGER.variables)
|
||||
assert.deepEqual(built.value, [1, 2, 3])
|
||||
})
|
||||
|
||||
test('present and absent carry no value at all', () => {
|
||||
const built = conditionsFromRows('and', [{ variable: 'house', cmp: 'present', value: 'ignored' }], TRIGGER.variables)
|
||||
assert.deepEqual(built, { variable: 'house', cmp: 'present' })
|
||||
})
|
||||
|
||||
test('no rows means no conditions — not an empty group that matches nothing', () => {
|
||||
assert.equal(conditionsFromRows('and', [], TRIGGER.variables), null)
|
||||
assert.equal(conditionsFromRows('and', [{ variable: '', cmp: '' }], TRIGGER.variables), null)
|
||||
})
|
||||
|
||||
test('a flat stored tree opens editable; a nested one opens read-only', () => {
|
||||
const flat = conditionRowsFrom({
|
||||
op: 'and',
|
||||
nodes: [{ variable: 'house', cmp: 'eq', value: 'x' }, { variable: 'daysLeft', cmp: 'gt', value: 5 }],
|
||||
})
|
||||
assert.equal(flat.editable, true)
|
||||
assert.equal(flat.rows.length, 2)
|
||||
|
||||
// `A AND (B OR C)` flattened to `A AND B AND C` fires on different events, and
|
||||
// the operator would have no way to know the save had done it.
|
||||
const nested = conditionRowsFrom({
|
||||
op: 'and',
|
||||
nodes: [
|
||||
{ variable: 'house', cmp: 'eq', value: 'x' },
|
||||
{ op: 'or', nodes: [{ variable: 'daysLeft', cmp: 'gt', value: 5 }] },
|
||||
],
|
||||
})
|
||||
assert.equal(nested.editable, false)
|
||||
assert.deepEqual(nested.rows, [])
|
||||
})
|
||||
|
||||
test('a single stored comparison is one editable row', () => {
|
||||
const one = conditionRowsFrom({ variable: 'house', cmp: 'eq', value: 'x' })
|
||||
assert.equal(one.editable, true)
|
||||
assert.deepEqual(one.rows, [{ variable: 'house', cmp: 'eq', value: 'x' }])
|
||||
})
|
||||
|
||||
// ── Segment composition ────────────────────────────────────────────────────
|
||||
|
||||
test('a members audience with no saved audience is warned about BEFORE the save', () => {
|
||||
// The trap the browser walk found: it is the default the moment a
|
||||
// members-ceiling trigger is chosen, and the rule it produces saves, switches
|
||||
// on and mails nobody. Nothing on the screen said so unless you pressed
|
||||
// Preview.
|
||||
assert.match(audienceWarning({ audience: 'members', audienceSegmentId: null }), /reaches nobody/)
|
||||
assert.equal(audienceWarning({ audience: 'members', audienceSegmentId: 4 }), null)
|
||||
assert.equal(audienceWarning({ audience: 'owner', audienceSegmentId: null }), null)
|
||||
})
|
||||
|
||||
test('the server says "segment"; the screens say "saved audience"', () => {
|
||||
// One word for one table in the API, the schema and the docs. But an operator
|
||||
// meets the concept under a heading that says "Audiences", and a sentence that
|
||||
// switches vocabulary mid-screen reads as being about something else.
|
||||
assert.equal(operatorWords('audience segment is dormant'), 'audience saved audience is dormant')
|
||||
assert.match(describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' }), /saved audience/)
|
||||
// and it does not maul a word that merely contains it
|
||||
assert.equal(operatorWords('segmented data'), 'segmented data')
|
||||
})
|
||||
|
||||
test('a list of nothing but exclusions is refused before the round trip', () => {
|
||||
// One checkbox away at all times, because the composer offers "exclude" on
|
||||
// every row including the only one. The server refuses it correctly — but
|
||||
// only after a save.
|
||||
const err = notPlacementError({ op: 'and', nodes: [{ op: 'not', nodes: [{ audienceId: 'a' }] }] })
|
||||
assert.match(err, /at least one audience/i)
|
||||
})
|
||||
|
||||
test('a bare not is refused before it reaches the server', () => {
|
||||
assert.ok(notPlacementError({ op: 'not', nodes: [{ audienceId: 'uo.governors' }] }))
|
||||
assert.ok(notPlacementError({ op: 'or', nodes: [{ audienceId: 'a' }, { op: 'not', nodes: [{ audienceId: 'b' }] }] }))
|
||||
})
|
||||
|
||||
test('a not under an "all of" is fine — that is the only universe that does not widen', () => {
|
||||
assert.equal(
|
||||
notPlacementError({
|
||||
op: 'and',
|
||||
nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }],
|
||||
}),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
test('an expression describes itself with module labels where it has them', () => {
|
||||
const byId = { 'uo.governors': { label: 'Governors' } }
|
||||
const said = describeExpression(
|
||||
{ op: 'and', nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }] },
|
||||
byId,
|
||||
)
|
||||
assert.equal(said, 'Governors and not uo.flagged')
|
||||
})
|
||||
|
||||
test('a leaf renders its parameters, so two rows built on the same audience are distinguishable', () => {
|
||||
const said = describeExpression({ audienceId: 'uo.team.members', params: { teamId: 4 } }, {})
|
||||
assert.equal(said, 'uo.team.members (teamId: 4)')
|
||||
})
|
||||
|
||||
// ── The list summary ───────────────────────────────────────────────────────
|
||||
|
||||
test('a rule summarises to what it will do, and always names its hourly cap', () => {
|
||||
const said = describeRule(row({ delay_seconds: 3600 }), { segmentsById: {} })
|
||||
assert.match(said, /to owner/)
|
||||
assert.match(said, /via email/)
|
||||
assert.match(said, /after 1 hour/)
|
||||
assert.match(said, /once per 1 day/)
|
||||
assert.match(said, /100\/hour/)
|
||||
})
|
||||
|
||||
test('a rule on a segment names the segment, not the ceiling column', () => {
|
||||
// The `audience` column on such a rule holds the segment's ceiling, which is a
|
||||
// fact about what it MAY reach and not about who it does.
|
||||
const said = describeRule(row({ audience: 'members', audience_segment_id: 7 }), {
|
||||
segmentsById: { 7: { name: 'Governors' } },
|
||||
})
|
||||
assert.match(said, /to Governors/)
|
||||
})
|
||||
|
||||
test('humanSeconds picks the coarsest EXACT unit, and never rounds', () => {
|
||||
assert.equal(humanSeconds(0), 'none')
|
||||
assert.equal(humanSeconds(3600), '1 hour')
|
||||
assert.equal(humanSeconds(86400), '1 day')
|
||||
assert.equal(humanSeconds(7200), '2 hours')
|
||||
assert.equal(humanSeconds(3660), '61 minutes')
|
||||
assert.equal(humanSeconds(90), '90 seconds')
|
||||
})
|
||||
@@ -160,6 +160,9 @@ test('the registry object handed to modules exposes the whole surface', () => {
|
||||
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
||||
// member missing from the object is a member that does not exist.
|
||||
assert.deepEqual(Object.keys(registry).sort(), [
|
||||
// `declareModuleSlot` is the INVERTED direction added in 1.6.0: the module
|
||||
// declares a place on its own page and core fills it (TEAMS.md Part 3).
|
||||
'declareModuleSlot',
|
||||
'featureProviderFor',
|
||||
'navFor',
|
||||
'registerExtension',
|
||||
|
||||
@@ -4,6 +4,10 @@ import assert from 'node:assert/strict'
|
||||
import {
|
||||
registry,
|
||||
declareSlot,
|
||||
declareModuleSlot,
|
||||
offerCoreFill,
|
||||
CORE_CONTRIBUTIONS,
|
||||
applyCoreFills,
|
||||
registerExtension,
|
||||
extensionFor,
|
||||
registeredIds,
|
||||
@@ -92,3 +96,114 @@ test('declareSlot and extensionFor are not on the module-facing registry', () =>
|
||||
assert.equal(registry.extensionFor, undefined)
|
||||
assert.equal(typeof registry.registerExtension, 'function')
|
||||
})
|
||||
|
||||
// ── The INVERTED direction: the module declares, core fills ────────────────
|
||||
//
|
||||
// Added in 1.6.0 for Teams (TEAMS.md Part 3). Teams are a core primitive with no
|
||||
// core surface — core owns the tables and the activity feed, the module owns the
|
||||
// page and the word "guild" — so the content flows the other way for the first
|
||||
// time. The rules below are the ones that direction gets wrong.
|
||||
|
||||
const Feed = () => null
|
||||
|
||||
test('a module-declared slot must be namespaced under the declaring module', () => {
|
||||
// Enforced rather than conventional: this is the only thing keeping two
|
||||
// modules from claiming the same slot name.
|
||||
assert.throws(() => declareModuleSlot('uo', 'guild.detail'), /must be namespaced/)
|
||||
assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
|
||||
})
|
||||
|
||||
test('core offers a contribution and the module says where it goes', () => {
|
||||
// The ordering that makes this two calls: core's bundle evaluates BEFORE any
|
||||
// module chunk, so at the moment core offers, no module-declared slot exists.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
assert.equal(extensionFor('uo.guild.detail'), null, 'not before the fills are applied')
|
||||
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('core names no slot, so a second game gets the same content in its own words', () => {
|
||||
// The defect this replaced: core used to fill three literal `uo.guild.*` names,
|
||||
// which reached exactly one module. Every other game declared a place under its
|
||||
// own id and got an empty page with no error, because a fill nobody declared is
|
||||
// deliberately not an error — the rule that makes an unknown name invisible.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('examplegame', 'examplegame.clan.detail', { core: 'team.activity' })
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('examplegame.clan.detail'), Feed)
|
||||
})
|
||||
|
||||
test('two modules can ask for the same contribution, and both get it', () => {
|
||||
// Core has no reason to care how many places want its feed, and refusing the
|
||||
// second would be core making a layout decision on a page it does not own.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
declareModuleSlot('uo', 'uo.guild.summary', { core: 'team.activity' })
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
assert.equal(extensionFor('uo.guild.summary'), Feed)
|
||||
})
|
||||
|
||||
test('a slot that asks for nothing stays empty', () => {
|
||||
// Optional on purpose: a module may declare a place it fills itself, or one it
|
||||
// is keeping for later. Neither is core's business.
|
||||
offerCoreFill('team.activity', Feed)
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), null)
|
||||
})
|
||||
|
||||
test('asking for a contribution core does not offer THROWS', () => {
|
||||
// The asymmetry with an unfilled slot, and it is deliberate. An unknown
|
||||
// contribution is always a typo or a version skew — core's list is fixed at
|
||||
// build time and the module's coreApi range has already been checked — and the
|
||||
// alternative failure is a page that renders empty forever with nothing logged.
|
||||
assert.throws(
|
||||
() => declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activityfeed' }),
|
||||
/does not offer/,
|
||||
)
|
||||
assert.ok(CORE_CONTRIBUTIONS['team.activity'], 'the catalogue is exported so a test can name it')
|
||||
})
|
||||
|
||||
test('a contribution nothing asks for is not an error', () => {
|
||||
// No game module installed. Core offering content for a page that does not
|
||||
// exist is the ordinary case on any deployment, not a misconfiguration.
|
||||
offerCoreFill('team.forum', Feed)
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
})
|
||||
|
||||
test('a module that fills its own slot first keeps it', () => {
|
||||
const Own = () => null
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
registerExtension('uo', 'uo.guild.detail', Own)
|
||||
offerCoreFill('team.activity', Feed)
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else')
|
||||
})
|
||||
|
||||
test('a module-declared slot cannot be declared twice', () => {
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
assert.throws(() => declareModuleSlot('uo', 'uo.guild.detail'), /already declared/)
|
||||
})
|
||||
|
||||
test('applying the fills twice does not re-fill or throw', () => {
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
offerCoreFill('team.activity', Feed)
|
||||
applyCoreFills()
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('a non-component contribution is refused at the call site, not at render', () => {
|
||||
assert.throws(() => offerCoreFill('team.activity', 'nope'), /is not a component/)
|
||||
})
|
||||
|
||||
test('_reset clears pending fills, so one test cannot leak into the next', () => {
|
||||
offerCoreFill('team.activity', Feed)
|
||||
_reset()
|
||||
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), null)
|
||||
})
|
||||
|
||||
42
client/test/notificationPaths.test.js
Normal file
42
client/test/notificationPaths.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ── Where each account's notification screens live ─────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 7. Three assertions for a nine-line module, because the
|
||||
// defect they pin was invisible to every other check: `/auth/me/notifications`
|
||||
// is role-agnostic (behind `requireAuth` only, like the rest of `/auth/me`), so
|
||||
// the server, the tests and the API all agreed a staff member had an inbox —
|
||||
// and on the web they could not reach it, because `RequirePlayer` sends anyone
|
||||
// who is not a player back out of `/account`. The bell pointed at a redirect.
|
||||
//
|
||||
// Found in the Phase 7 rig, signed in as an admin. What stops it coming back is
|
||||
// this file plus the two admin routes it maps onto.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { isStaff, inboxPath, notificationSettingsPath } from '../src/lib/notificationPaths.js'
|
||||
|
||||
test('a player gets the portal paths', () => {
|
||||
const user = { role: 'player' }
|
||||
assert.equal(isStaff(user), false)
|
||||
assert.equal(inboxPath(user), '/account/notifications')
|
||||
assert.equal(notificationSettingsPath(user), '/account/notifications/settings')
|
||||
})
|
||||
|
||||
test('every non-player role gets the admin paths, not just admin', () => {
|
||||
for (const role of ['admin', 'editor', 'moderator']) {
|
||||
const user = { role }
|
||||
assert.equal(isStaff(user), true, role)
|
||||
assert.equal(inboxPath(user), '/admin/notifications', role)
|
||||
assert.equal(notificationSettingsPath(user), '/admin/notifications/settings', role)
|
||||
}
|
||||
})
|
||||
|
||||
// The bell renders nothing when signed out, so these are never asked for a null
|
||||
// user in practice — but a default that guessed "staff" would send a signed-out
|
||||
// visitor at the admin area the moment that changed.
|
||||
test('no user, or a user with no role, falls back to the player paths', () => {
|
||||
for (const user of [null, undefined, {}, { role: '' }]) {
|
||||
assert.equal(isStaff(user), false)
|
||||
assert.equal(inboxPath(user), '/account/notifications')
|
||||
}
|
||||
})
|
||||
59
client/test/pageShell.test.js
Normal file
59
client/test/pageShell.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { shellClass, SHELL_WIDTHS } from '../src/lib/pageShell.js'
|
||||
|
||||
// `PublicLayout`'s `shell` prop (MODULE_API.md §3.4, MODULE_API_VERSION 1.5.0).
|
||||
// The component itself is .jsx and unreachable from this runner — there is no DOM
|
||||
// here — so the rule lives in lib/pageShell.js and is asserted here, and the
|
||||
// rendering is proved in a browser (MODULE_API.md §7.7), which is where the
|
||||
// defect that produced this prop was found in the first place.
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
test('no shell means no wrapper — the behaviour every page had before 1.5.0', () => {
|
||||
// null, not an empty string: PublicLayout branches on it to render `children`
|
||||
// bare, and '' would render a <div class=""> that changes core's nine pages.
|
||||
assert.equal(shellClass(undefined), null)
|
||||
assert.equal(shellClass(null), null)
|
||||
assert.equal(shellClass(''), null)
|
||||
assert.equal(shellClass(false), null)
|
||||
})
|
||||
|
||||
test('each documented width maps to its theme.css class, plus page-body', () => {
|
||||
assert.equal(shellClass('narrow'), 'shell-narrow page-body')
|
||||
assert.equal(shellClass('mid'), 'shell-mid page-body')
|
||||
assert.equal(shellClass('wide'), 'shell-wide page-body')
|
||||
})
|
||||
|
||||
test('page-body is always present — it is what pushes the footer down', () => {
|
||||
// `.page` is a flex column and `.page-body { flex: 1 }` is the only thing
|
||||
// filling it. A width class on its own centres the content and still lets the
|
||||
// footer ride up under it, which is half the reported defect and the half that
|
||||
// is easy to lose in a refactor.
|
||||
for (const w of SHELL_WIDTHS) {
|
||||
assert.match(shellClass(w), /\bpage-body\b/)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown width still renders a wrapper, at the narrow default', () => {
|
||||
// The value can arrive from a module built against a different version of this
|
||||
// list, so the failure mode has to be "wrong width" and never "no wrapper".
|
||||
assert.equal(shellClass('enormous'), 'shell-narrow page-body')
|
||||
assert.equal(shellClass(true), 'shell-narrow page-body')
|
||||
assert.equal(shellClass('NARROW'), 'shell-narrow page-body')
|
||||
})
|
||||
|
||||
test('every width this module offers is a class theme.css actually defines', () => {
|
||||
// The contract now names these widths to module authors, so a rename in
|
||||
// theme.css has to fail here rather than silently in a module's page.
|
||||
const css = fs.readFileSync(path.join(HERE, '../src/styles/theme.css'), 'utf8')
|
||||
for (const w of SHELL_WIDTHS) {
|
||||
const cls = shellClass(w).split(' ')[0]
|
||||
assert.ok(css.includes(`.${cls} {`), `theme.css defines .${cls}`)
|
||||
}
|
||||
assert.ok(css.includes('.page-body {'), 'theme.css defines .page-body')
|
||||
})
|
||||
78
client/test/teamActivity.test.js
Normal file
78
client/test/teamActivity.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// What core's Team activity feed says (client/src/lib/teamActivity.js).
|
||||
//
|
||||
// The test that earns this file: a projection nobody can tell is stale, and a
|
||||
// feed nobody can tell is filtered, both look like complete information. Every
|
||||
// case below is about saying which one the reader is looking at.
|
||||
//
|
||||
// Note the wording assertions avoid core's own noun. The feed renders inside a
|
||||
// page a MODULE titled — Guilds today, Clans next — so "this Team" would be
|
||||
// core's vocabulary leaking onto a surface that deliberately does not use it.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { activityScopeNote, freshnessNote, groupByDay, relativeTime } from '../src/lib/teamActivity.js'
|
||||
|
||||
const NOW = new Date('2026-08-17T12:00:00Z').getTime()
|
||||
const ago = (ms) => new Date(NOW - ms).toISOString()
|
||||
|
||||
test('a deployment with no provider is not stale, it is uninvolved', () => {
|
||||
assert.equal(freshnessNote({ configured: false }, NOW), null)
|
||||
})
|
||||
|
||||
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.match(note.text, /Not yet confirmed/)
|
||||
})
|
||||
|
||||
test('a stale projection says how old it is and that the game may have moved on', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
|
||||
})
|
||||
|
||||
test('a current projection is stated quietly', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
|
||||
assert.equal(note.tone, 'idle')
|
||||
assert.equal(note.text, 'Last confirmed 1 minute ago.')
|
||||
})
|
||||
|
||||
test('relative time singularises and steps through the units', () => {
|
||||
assert.equal(relativeTime(ago(5_000), NOW), 'just now')
|
||||
assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
|
||||
assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
|
||||
assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
|
||||
assert.equal(relativeTime(null, NOW), null)
|
||||
assert.equal(relativeTime('not a date', NOW), null)
|
||||
})
|
||||
|
||||
test('items group into days, newest day first, order kept within a day', () => {
|
||||
const days = groupByDay([
|
||||
{ id: 3, occurredAt: '2026-08-17T09:00:00' },
|
||||
{ id: 2, occurredAt: '2026-08-17T08:00:00' },
|
||||
{ id: 1, occurredAt: '2026-08-16T22:00:00' },
|
||||
], 'en-US')
|
||||
assert.equal(days.length, 2)
|
||||
assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
|
||||
assert.deepEqual(days[1].items.map((i) => i.id), [1])
|
||||
})
|
||||
|
||||
test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
|
||||
assert.deepEqual(groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US'), [])
|
||||
})
|
||||
|
||||
test('a caller who saw everything is told nothing', () => {
|
||||
assert.equal(activityScopeNote({ scope: 'members' }, true), null)
|
||||
})
|
||||
|
||||
test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
|
||||
assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
|
||||
assert.match(activityScopeNote({ scope: 'public' }, true), /members only/)
|
||||
})
|
||||
|
||||
test('the wording never says "Team" — that is core\'s noun, not the page\'s', () => {
|
||||
for (const signedIn of [true, false]) {
|
||||
assert.doesNotMatch(activityScopeNote({ scope: 'public' }, signedIn), /Team/)
|
||||
}
|
||||
assert.doesNotMatch(freshnessNote({ configured: true, lastSyncAt: null }, NOW).text, /Team/)
|
||||
})
|
||||
140
client/test/teamAdmin.test.js
Normal file
140
client/test/teamAdmin.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// What Admin → Teams says (client/src/lib/teamAdmin.js).
|
||||
//
|
||||
// The test that earns this file: "no Teams" and "core has not been able to ask"
|
||||
// must never read the same. They produce almost identical screens — an empty
|
||||
// table — and one is fine while the other is an outage an operator needs to act
|
||||
// on. Everything else here is in service of that distinction.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
freshnessOf, ago, statusOf, gateLabelFor, describeRequest, parsePayload, leadershipOf, TONE,
|
||||
} from '../src/lib/teamAdmin.js'
|
||||
|
||||
const minutesAgo = (n) => new Date(Date.now() - n * 60_000).toISOString()
|
||||
|
||||
// ── Freshness: four states that must not be confused ───────────────────────
|
||||
|
||||
test('no provider is idle, not a fault', () => {
|
||||
const f = freshnessOf({ configured: false })
|
||||
assert.equal(f.tone, TONE.idle)
|
||||
assert.match(f.label, /No Team provider/)
|
||||
})
|
||||
|
||||
test('never synced is reported as never synced, not as an empty shard', () => {
|
||||
// The failure this prevents: an empty projection core has never confirmed,
|
||||
// rendered as though the game genuinely has no Teams.
|
||||
const f = freshnessOf({ configured: true, lastSyncAt: null })
|
||||
assert.equal(f.tone, TONE.bad)
|
||||
assert.equal(f.label, 'Never synced')
|
||||
assert.match(f.detail, /not a confirmed empty shard/)
|
||||
})
|
||||
|
||||
test('stale says how old it is', () => {
|
||||
const f = freshnessOf({ configured: true, stale: true, lastSyncAt: minutesAgo(14) })
|
||||
assert.equal(f.tone, TONE.warn)
|
||||
assert.equal(f.label, 'Stale')
|
||||
assert.match(f.detail, /14 minutes ago/)
|
||||
})
|
||||
|
||||
test('current says so plainly', () => {
|
||||
const f = freshnessOf({ configured: true, stale: false, lastSyncAt: minutesAgo(2) })
|
||||
assert.equal(f.tone, TONE.ok)
|
||||
assert.equal(f.label, 'Current')
|
||||
})
|
||||
|
||||
test('ago is deliberately coarse', () => {
|
||||
// Second-level precision would be false comfort about a projection whose poll
|
||||
// interval is fifteen minutes.
|
||||
assert.equal(ago(null), 'never')
|
||||
assert.equal(ago(new Date().toISOString()), 'just now')
|
||||
assert.equal(ago(minutesAgo(14)), '14 minutes ago')
|
||||
assert.equal(ago(minutesAgo(60)), '1 hour ago')
|
||||
assert.equal(ago(minutesAgo(180)), '3 hours ago')
|
||||
assert.equal(ago(minutesAgo(60 * 72)), '3 days ago')
|
||||
})
|
||||
|
||||
// ── Status ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('the four Team statuses are distinguishable', () => {
|
||||
assert.equal(statusOf({ status: 'active' }).label, 'Public')
|
||||
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).label, 'Hidden — reserved name')
|
||||
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).label, 'Hidden by staff')
|
||||
assert.equal(statusOf({ status: 'archived', archivedReason: 'disbanded' }).label, 'Archived')
|
||||
assert.equal(statusOf({ status: 'archived', archivedReason: 'renamed' }).label, 'Renamed')
|
||||
})
|
||||
|
||||
test('a reserved-name hide is the loudest tone', () => {
|
||||
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).tone, TONE.bad)
|
||||
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).tone, TONE.warn)
|
||||
})
|
||||
|
||||
// ── The gate, described honestly ───────────────────────────────────────────
|
||||
|
||||
test('the button says what will actually happen for this role', () => {
|
||||
// The server decides from the live role; this only describes it. Saying
|
||||
// "Publish" to a moderator would make the pending result a surprise.
|
||||
assert.equal(gateLabelFor('admin', 'Publish'), 'Publish')
|
||||
assert.equal(gateLabelFor('moderator', 'Publish'), 'Request publish')
|
||||
})
|
||||
|
||||
// ── The approval queue ─────────────────────────────────────────────────────
|
||||
|
||||
test('a request describes itself, including the name being published', () => {
|
||||
assert.equal(
|
||||
describeRequest({ action: 'unhide', requested_username: 'mod1', team_name: 'Admin' }),
|
||||
'mod1 asks to publish “Admin”',
|
||||
)
|
||||
assert.equal(
|
||||
describeRequest({
|
||||
action: 'display_name_override', requested_username: 'mod1', team_name: 'Admin',
|
||||
payload: { displayName: 'The Old Guard' },
|
||||
}),
|
||||
'mod1 asks to display “Admin” as “The Old Guard”',
|
||||
)
|
||||
assert.equal(
|
||||
describeRequest({ action: 'clear_display_name_override', requested_username: 'mod1', team_name: 'X' }),
|
||||
'mod1 asks to clear the display name on “X”',
|
||||
)
|
||||
})
|
||||
|
||||
test('a deleted requester still reads as a sentence', () => {
|
||||
// §2.10 sets requested_by to NULL and keeps the username snapshot; when even
|
||||
// that is gone the queue must not render "null asks to publish".
|
||||
assert.match(describeRequest({ action: 'unhide', team_name: 'Admin' }), /^a deleted user asks/)
|
||||
})
|
||||
|
||||
test('a payload arrives parsed or as a string, and both work', () => {
|
||||
assert.deepEqual(parsePayload({ displayName: 'X' }), { displayName: 'X' })
|
||||
assert.deepEqual(parsePayload('{"displayName":"X"}'), { displayName: 'X' })
|
||||
assert.deepEqual(parsePayload(null), {})
|
||||
assert.deepEqual(parsePayload('not json'), {})
|
||||
})
|
||||
|
||||
// ── Leadership shows the decision, not just the answer ─────────────────────
|
||||
|
||||
test('an unoverridden member reads straight from the projection', () => {
|
||||
const l = leadershipOf({ isLeader: true, isLeaderSynced: true })
|
||||
assert.equal(l.isLeader, true)
|
||||
assert.equal(l.overridden, false)
|
||||
assert.equal(l.note, null)
|
||||
})
|
||||
|
||||
test('an override is shown AS an override, with what the game says', () => {
|
||||
// Staff looking at a roster need to see that a decision was made, not a fact
|
||||
// that looks like the game's.
|
||||
const l = leadershipOf({
|
||||
isLeaderSynced: true,
|
||||
leaderOverride: { effect: 'deny', by: 'mod1', reason: 'harassment' },
|
||||
})
|
||||
assert.equal(l.isLeader, false)
|
||||
assert.equal(l.overridden, true)
|
||||
assert.match(l.note, /Denied by mod1 — harassment/)
|
||||
assert.match(l.note, /the game says leader/)
|
||||
})
|
||||
|
||||
test('a grant override says the game disagrees', () => {
|
||||
const l = leadershipOf({ isLeaderSynced: false, leaderOverride: { effect: 'grant', by: 'root' } })
|
||||
assert.equal(l.isLeader, true)
|
||||
assert.match(l.note, /the game says not a leader/)
|
||||
})
|
||||
120
client/test/teamForum.test.js
Normal file
120
client/test/teamForum.test.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// What the Team forum's client half decides for itself (client/src/lib/teamForum.js).
|
||||
//
|
||||
// The point of this file is how LITTLE that is. Who may post, who may moderate,
|
||||
// whether an image renders and whether a post may be edited are all server
|
||||
// answers the panel reads. What is tested here is the three places the client
|
||||
// turns those answers into what a reader sees — and one property that is easy to
|
||||
// break by accident: the edit offer can only ever be withdrawn here, never
|
||||
// granted.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../src/lib/teamForum.js'
|
||||
|
||||
const NOW = new Date('2026-08-18T12:00:00Z').getTime()
|
||||
const inMinutes = (n) => new Date(NOW + n * 60_000).toISOString()
|
||||
|
||||
// ── the edit offer ─────────────────────────────────────────────────────────
|
||||
|
||||
test('the client can withdraw an edit offer and can never create one', () => {
|
||||
// The server said no. Nothing about a deadline changes that — a future
|
||||
// `editableUntil` on a post the server refused must not become an offer, or
|
||||
// the client would be granting a permission.
|
||||
assert.equal(editOfferOpen({ canEdit: false, editableUntil: inMinutes(10) }, NOW), false)
|
||||
assert.equal(editOfferOpen({ canEdit: false, editableUntil: null }, NOW), false)
|
||||
})
|
||||
|
||||
test('a deadline that has passed while the page sat open withdraws the offer', () => {
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW), true)
|
||||
// Same post, fifteen minutes of the reader staring at it later.
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW + 15 * 60_000), false)
|
||||
})
|
||||
|
||||
test('no deadline means no deadline, not no permission', () => {
|
||||
// Staff are not time-bounded, and `editableUntil: null` is how the server says
|
||||
// so. Reading it as "expired" would take the edit control away from exactly the
|
||||
// people whose authority does not expire.
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: null }, NOW), true)
|
||||
})
|
||||
|
||||
test('an unparseable deadline closes the offer rather than opening it', () => {
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: 'not a date' }, NOW), false)
|
||||
assert.equal(editOfferOpen(null, NOW), false)
|
||||
assert.equal(editOfferOpen(undefined, NOW), false)
|
||||
})
|
||||
|
||||
// ── round-tripping a body back into the composer ───────────────────────────
|
||||
|
||||
test('the image core generated is stripped, and the URL that made it survives', () => {
|
||||
// §5.5.3: the author wrote a URL, core emitted the <img> at read time. Handing
|
||||
// the <img> back would let an author edit markup they never wrote — and the
|
||||
// URL is what re-renders it, so nothing is lost by removing it.
|
||||
const rendered = '<p><a href="https://x/a.png" rel="noopener noreferrer">https://x/a.png</a>'
|
||||
+ '<img src="https://x/a.png" class="forum-embed" referrerpolicy="no-referrer" /></p>'
|
||||
const text = stripToText(rendered)
|
||||
assert.ok(!text.includes('<img'))
|
||||
assert.ok(text.includes('https://x/a.png'))
|
||||
})
|
||||
|
||||
test('paragraphs become blank lines and breaks become newlines', () => {
|
||||
assert.equal(stripToText('<p>One</p><p>Two</p>'), 'One\n\nTwo')
|
||||
assert.equal(stripToText('<p>One<br>Two</p>'), 'One\nTwo')
|
||||
// A paragraph carrying attributes is still a paragraph.
|
||||
assert.equal(stripToText('<p>One</p>\n<p class="x">Two</p>'), 'One\n\nTwo')
|
||||
})
|
||||
|
||||
test('entities decode to what the author typed, and only once', () => {
|
||||
assert.equal(stripToText('<p>Tom & Jerry</p>'), 'Tom & Jerry')
|
||||
assert.equal(stripToText('<p>"quoted"</p>'), '"quoted"')
|
||||
|
||||
// The one that bites: an author who typed a literal "<script>" has it stored
|
||||
// escaped. Decoding entities BEFORE stripping tags would turn it into a real
|
||||
// tag that the strip pass then deletes — silently losing text the author wrote
|
||||
// and which was never dangerous.
|
||||
assert.equal(stripToText('<p><script></p>'), '<script>')
|
||||
// And decoding & first would turn "&lt;" into "<" in two steps.
|
||||
assert.equal(stripToText('<p>&lt;</p>'), '<')
|
||||
})
|
||||
|
||||
test('an empty or absent body is an empty string, never a crash', () => {
|
||||
assert.equal(stripToText(''), '')
|
||||
assert.equal(stripToText(null), '')
|
||||
assert.equal(stripToText(undefined), '')
|
||||
assert.equal(stripToText('<p></p>'), '')
|
||||
})
|
||||
|
||||
// ── the thread list line ───────────────────────────────────────────────────
|
||||
|
||||
test('a discussion counts REPLIES, which is one fewer than its posts', () => {
|
||||
// postCount includes the opening post. Showing it raw would tell a reader a
|
||||
// brand-new thread already has one reply.
|
||||
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 1 }), 'ada')
|
||||
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 2 }), 'ada · 1 reply')
|
||||
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 4 }), 'ada · 3 replies')
|
||||
})
|
||||
|
||||
test('an announcement says so and never counts replies, because it takes none', () => {
|
||||
const line = threadSummary({ type: 'announcement', author: 'aldric', postCount: 1 })
|
||||
assert.equal(line, 'Announcement · aldric')
|
||||
assert.ok(!line.includes('repl'))
|
||||
})
|
||||
|
||||
test('hidden is said out loud — it is only shown to whoever can unhide it', () => {
|
||||
assert.equal(
|
||||
threadSummary({ type: 'discussion', author: 'ada', postCount: 1, status: 'hidden' }),
|
||||
'ada · hidden',
|
||||
)
|
||||
})
|
||||
|
||||
// ── the report control ─────────────────────────────────────────────────────
|
||||
|
||||
test('every reason the server accepts is offered, and no others', () => {
|
||||
// The server validates against its own list; a client offering a reason the
|
||||
// server rejects produces a 400 the reporter cannot act on, and one MISSING a
|
||||
// reason quietly funnels those reports into "other".
|
||||
assert.deepEqual(
|
||||
REPORT_REASONS.map(([value]) => value).sort(),
|
||||
['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'],
|
||||
)
|
||||
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
|
||||
})
|
||||
129
client/test/teamIntegrations.test.js
Normal file
129
client/test/teamIntegrations.test.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// What Admin → Teams → Notification bridge decides (client/src/lib/teamIntegrations.js).
|
||||
//
|
||||
// The test that earns this file: **repointing a row must not carry its
|
||||
// acknowledgement across.** That is the one way this screen could actively
|
||||
// mislead — an operator confirms a private channel, changes the id to a public
|
||||
// one, and the form still shows the confirmation as standing. The server clears
|
||||
// it either way, so the failure would be a screen that disagrees with the answer
|
||||
// it is about to get, which is worse than one that simply refuses.
|
||||
//
|
||||
// The rest is the boundary of the confirmation dialog: it must open when it
|
||||
// matters and stay shut when it does not, because a dialog that appears on saves
|
||||
// that did not need it is one people learn to click through.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
|
||||
setChannel, carriesMembersOnly, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
|
||||
} from '../src/lib/teamIntegrations.js'
|
||||
|
||||
const MEMBERS_ONLY = ['team.forum.post', 'team.announcement']
|
||||
const ROSTER = 'team.member.joined'
|
||||
const FORUM = 'team.forum.post'
|
||||
|
||||
const draft = (over = {}) => ({ ...blankDraft(null), ...over })
|
||||
|
||||
// ── The acknowledgement dies with its channel ──────────────────────────────
|
||||
|
||||
test('changing the channel drops a standing acknowledgement', () => {
|
||||
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
|
||||
const after = setChannel(before, '222')
|
||||
assert.equal(after.membersAck, false)
|
||||
assert.equal(after.channelRef, '222')
|
||||
})
|
||||
|
||||
test('setting the SAME channel does not clear it — an unrelated re-render is not a repoint', () => {
|
||||
const before = draft({ channelRef: '111', membersAck: true })
|
||||
const after = setChannel(before, '111')
|
||||
assert.equal(after.membersAck, true)
|
||||
assert.equal(after, before, 'and the object is returned unchanged, so nothing re-renders')
|
||||
})
|
||||
|
||||
test('a repointed row needs the dialog again, which is the whole point of clearing it', () => {
|
||||
const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true })
|
||||
assert.equal(needsAcknowledgement(before, MEMBERS_ONLY), false)
|
||||
assert.equal(needsAcknowledgement(setChannel(before, '222'), MEMBERS_ONLY), true)
|
||||
})
|
||||
|
||||
// ── When the dialog opens ──────────────────────────────────────────────────
|
||||
|
||||
test('enabling a forum event without the tick asks first', () => {
|
||||
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), MEMBERS_ONLY), true)
|
||||
})
|
||||
|
||||
test('a DISABLED draft carrying forum events does not ask — nothing is being published yet', () => {
|
||||
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: false }), MEMBERS_ONLY), false)
|
||||
})
|
||||
|
||||
test('a roster-only bridge never asks, however it is configured', () => {
|
||||
assert.equal(needsAcknowledgement(draft({ events: [ROSTER], enabled: true }), MEMBERS_ONLY), false)
|
||||
assert.equal(carriesMembersOnly(draft({ events: [ROSTER] }), MEMBERS_ONLY), false)
|
||||
})
|
||||
|
||||
test('an acknowledgement already given means no second dialog for an unrelated edit', () => {
|
||||
const d = draft({ events: [FORUM], enabled: true, membersAck: true, channelRef: '111' })
|
||||
const withRoster = toggleEvent(d, ROSTER)
|
||||
assert.equal(needsAcknowledgement(withRoster, MEMBERS_ONLY), false)
|
||||
})
|
||||
|
||||
test('the members-only set comes from the server, not from a list held here', () => {
|
||||
// The client must not decide what is members-only: a future stream added
|
||||
// server-side would silently escape a hardcoded client list.
|
||||
assert.deepEqual(
|
||||
membersOnlyIdsOf([{ id: ROSTER, membersOnly: false }, { id: FORUM, membersOnly: true }]),
|
||||
[FORUM],
|
||||
)
|
||||
// Told nothing is members-only, the dialog never opens — the server is the one
|
||||
// that would then refuse, which is the correct division.
|
||||
assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), []), false)
|
||||
})
|
||||
|
||||
// ── Events, rows and targets ───────────────────────────────────────────────
|
||||
|
||||
test('toggling adds then removes, and preserves selection order', () => {
|
||||
let d = draft()
|
||||
d = toggleEvent(d, FORUM)
|
||||
d = toggleEvent(d, ROSTER)
|
||||
assert.deepEqual(d.events, [FORUM, ROSTER])
|
||||
d = toggleEvent(d, FORUM)
|
||||
assert.deepEqual(d.events, [ROSTER])
|
||||
})
|
||||
|
||||
test('the default row is identified by a NULL team, and an undefined one counts too', () => {
|
||||
assert.equal(isDefaultRow({ team_id: null }), true)
|
||||
assert.equal(isDefaultRow({}), true)
|
||||
assert.equal(isDefaultRow({ team_id: 4 }), false)
|
||||
assert.equal(rowKey({ team_id: null }), 'default')
|
||||
assert.equal(rowKey({ team_id: 4 }), '4')
|
||||
})
|
||||
|
||||
test('a row is labelled by the staff override first, then the name, then its id', () => {
|
||||
assert.equal(appliesToLabel({ team_id: null }), 'All Teams')
|
||||
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real', display_name_override: 'Shown' }), 'Shown')
|
||||
assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real' }), 'Real')
|
||||
assert.equal(appliesToLabel({ team_id: 4 }), 'Team #4')
|
||||
})
|
||||
|
||||
test('a Team that already has an override is not offered a second one', () => {
|
||||
const rows = [{ team_id: null }, { team_id: 2 }]
|
||||
const teams = [{ id: 1, status: 'active' }, { id: 2, status: 'active' }, { id: 3, status: 'archived' }]
|
||||
const { hasDefault, teams: available } = availableTargets(rows, teams)
|
||||
assert.equal(hasDefault, true)
|
||||
assert.deepEqual(available.map((t) => t.id), [1], 'the taken one and the archived one are both out')
|
||||
})
|
||||
|
||||
test('with no default configured, the default is still offered', () => {
|
||||
const { hasDefault } = availableTargets([{ team_id: 2 }], [])
|
||||
assert.equal(hasDefault, false)
|
||||
})
|
||||
|
||||
test('a row round-trips through the draft without changing what it means', () => {
|
||||
const row = { team_id: 4, events: [FORUM], channel_ref: '111', enabled: 1, members_ack: 1 }
|
||||
assert.deepEqual(draftFrom(row), { teamId: 4, events: [FORUM], channelRef: '111', enabled: true, membersAck: true })
|
||||
})
|
||||
|
||||
test('an unknown event id renders as itself rather than as blank', () => {
|
||||
assert.equal(eventLabel(FORUM), 'New forum post')
|
||||
assert.equal(eventLabel('team.something.new'), 'team.something.new')
|
||||
})
|
||||
87
client/test/teamNotify.test.js
Normal file
87
client/test/teamNotify.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { api } from '../src/api/client.js'
|
||||
|
||||
// The client half of Team notifications (docs/website/TEAMS.md Part 6, phase 6).
|
||||
//
|
||||
// There is no DOM in this runner, so what is asserted here is the WIRE — which is
|
||||
// where this feature's client-side mistakes actually live. Two of them have
|
||||
// already been made once in this repo and are recorded rather than re-derived:
|
||||
//
|
||||
// 1. **A PUT-the-whole-set body must always carry its array**, empty included.
|
||||
// `docs/android/PLAN.md` §11: a DTO field with a default is dropped by
|
||||
// kotlinx when it equals that default, so "clear the last entry" arrives as a
|
||||
// body with no array at all and 400s. The web client has no such
|
||||
// serialisation quirk, but it shares the endpoint's contract, and a test that
|
||||
// pins the shape here is what keeps the two clients honest about the same
|
||||
// rule.
|
||||
// 2. **The unsubscribe call is a POST**, not the GET the link in the mail was.
|
||||
// A GET that mutated would be triggered by every mail-client link scanner.
|
||||
|
||||
let calls
|
||||
const realFetch = global.fetch
|
||||
|
||||
function reply(body = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
calls.push({ url, opts })
|
||||
return reply({ teams: [], streams: [], ok: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => { global.fetch = realFetch })
|
||||
|
||||
const body = (i = 0) => JSON.parse(calls[i].opts.body)
|
||||
|
||||
test('the per-Team preference endpoints sit under /auth/me, not /player', async () => {
|
||||
await api.teamNotificationPrefs()
|
||||
// Role-agnostic self-service, the same rule that put the Team forum under
|
||||
// /player rather than behind a staff gate: staff are a superset of players and
|
||||
// manage their own notifications like anyone else.
|
||||
assert.match(calls[0].url, /\/auth\/me\/notifications\/teams$/)
|
||||
assert.equal(calls[0].opts.method ?? 'GET', 'GET')
|
||||
})
|
||||
|
||||
test('saving preferences PUTs the whole set under a `teams` key', async () => {
|
||||
await api.setTeamNotificationPrefs([{ teamId: 3, muted: true, emailMode: 'digest' }])
|
||||
assert.equal(calls[0].opts.method, 'PUT')
|
||||
assert.deepEqual(body(), { teams: [{ teamId: 3, muted: true, emailMode: 'digest' }] })
|
||||
})
|
||||
|
||||
test('clearing every preference still sends the array, never an absent key', async () => {
|
||||
await api.setTeamNotificationPrefs([])
|
||||
assert.deepEqual(body(), { teams: [] })
|
||||
assert.equal('teams' in body(), true)
|
||||
})
|
||||
|
||||
test('the same rule holds for the stream subscriptions beside them', async () => {
|
||||
await api.setNotificationSubscriptions([])
|
||||
assert.deepEqual(body(), { streams: [] })
|
||||
})
|
||||
|
||||
test('unsubscribe is a POST to the public tier, with the token encoded into the path', async () => {
|
||||
await api.unsubscribeTeam('1.7.3.abcDEF')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.match(calls[0].url, /\/public\/teams\/unsubscribe\/1\.7\.3\.abcDEF$/)
|
||||
})
|
||||
|
||||
test('a token with url-unsafe characters is encoded rather than pasted in', async () => {
|
||||
await api.unsubscribeTeam('a/b c')
|
||||
assert.match(calls[0].url, /unsubscribe\/a%2Fb%20c$/)
|
||||
})
|
||||
|
||||
test('the streams catalog and subscriptions are separate reads', async () => {
|
||||
await api.notificationStreams()
|
||||
await api.notificationSubscriptions()
|
||||
assert.match(calls[0].url, /\/notifications\/streams$/)
|
||||
assert.match(calls[1].url, /\/notifications\/subscriptions$/)
|
||||
})
|
||||
152
client/test/teamVoice.test.js
Normal file
152
client/test/teamVoice.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// Admin → Teams → Voice channels, the decisions (TEAMS.md §7.3, phase 9).
|
||||
//
|
||||
// These mirror server rules and do not replace them: the server refuses to enable
|
||||
// voice while the bot cannot act, and the reconciler applies the threshold and the
|
||||
// grace window, whether or not this file ever ran. What is asserted here is that
|
||||
// the SCREEN agrees with those answers instead of offering a control that will
|
||||
// fail, or describing a state the deployment is not in.
|
||||
//
|
||||
// The one that matters most is `statusSummary`'s "off" branch. Switching voice off
|
||||
// suspends the reconciler in both directions and deliberately leaves existing
|
||||
// channels standing — a checkbox must not delete structure in somebody's guild —
|
||||
// and an operator who reads "off" as "nothing is provisioned" would never go
|
||||
// looking for the channels that are still there.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
stateLabel, enableBlockedReason, roleHeadroom, removalCountdown,
|
||||
parseStaffRoles, formatStaffRoles, statusSummary,
|
||||
} from '../src/lib/teamVoice.js'
|
||||
|
||||
test('every state the server can report has wording', () => {
|
||||
for (const state of ['none', 'active', 'pending_removal', 'error']) {
|
||||
assert.notEqual(stateLabel(state), state)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unknown state falls back to itself rather than rendering blank', () => {
|
||||
assert.equal(stateLabel('something-new'), 'something-new')
|
||||
})
|
||||
|
||||
// ── The enable gate ────────────────────────────────────────────────────────
|
||||
|
||||
test('a ready bot blocks nothing', () => {
|
||||
assert.equal(enableBlockedReason({ ready: true, connected: true, missingPermissions: [] }), null)
|
||||
})
|
||||
|
||||
test('a disconnected bot and a bot missing a permission read differently', () => {
|
||||
const disconnected = enableBlockedReason({ ready: false, connected: false, reason: 'the bot is not connected to Discord' })
|
||||
const missing = enableBlockedReason({ ready: false, connected: true, missingPermissions: ['Manage Roles'] })
|
||||
assert.match(disconnected, /not connected/)
|
||||
assert.match(missing, /Manage Roles/)
|
||||
// An operator fixes these in two completely different places, so collapsing
|
||||
// them into one message would send half of them to the wrong one.
|
||||
assert.notEqual(disconnected, missing)
|
||||
})
|
||||
|
||||
test('an absent preflight blocks rather than silently allowing', () => {
|
||||
assert.ok(enableBlockedReason(null))
|
||||
assert.ok(enableBlockedReason(undefined))
|
||||
})
|
||||
|
||||
// ── The role ceiling ───────────────────────────────────────────────────────
|
||||
|
||||
test('headroom is counted against the guild-wide cap', () => {
|
||||
const h = roleHeadroom({ roleCount: 200, roleCap: 250 })
|
||||
assert.equal(h.free, 50)
|
||||
assert.equal(h.tight, false)
|
||||
assert.equal(h.exhausted, false)
|
||||
})
|
||||
|
||||
test('a nearly full guild is flagged before the create fails, not after', () => {
|
||||
// The whole reason this is in the panel: access is a per-Team role, so the cap
|
||||
// limits how many TEAMS can have voice, and an operator with sixty guilds needs
|
||||
// to know that before the sixtieth silently errors.
|
||||
const h = roleHeadroom({ roleCount: 240, roleCap: 250 })
|
||||
assert.equal(h.tight, true)
|
||||
assert.equal(h.exhausted, false)
|
||||
})
|
||||
|
||||
test('a full guild is exhausted, and never reports negative headroom', () => {
|
||||
const h = roleHeadroom({ roleCount: 260, roleCap: 250 })
|
||||
assert.equal(h.free, 0)
|
||||
assert.equal(h.exhausted, true)
|
||||
})
|
||||
|
||||
test('no preflight means no claim about headroom', () => {
|
||||
assert.equal(roleHeadroom(null), null)
|
||||
assert.equal(roleHeadroom({}), null)
|
||||
})
|
||||
|
||||
// ── The grace window ───────────────────────────────────────────────────────
|
||||
|
||||
test('a row that is not scheduled has no countdown', () => {
|
||||
assert.equal(removalCountdown({ state: 'active', removeAfter: null }), null)
|
||||
})
|
||||
|
||||
test('a running window reads in days', () => {
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-24T00:00:00Z' }, now)
|
||||
assert.equal(text, 'in 5 days')
|
||||
})
|
||||
|
||||
test('under a day reads in hours rather than rounding to zero days', () => {
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-19T06:00:00Z' }, now)
|
||||
assert.equal(text, 'in 6 hours')
|
||||
})
|
||||
|
||||
test('an expired window says the next pass will act, not "in 0 days"', () => {
|
||||
const now = new Date('2026-08-19T00:00:00Z')
|
||||
const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-18T00:00:00Z' }, now)
|
||||
assert.match(text, /next pass/)
|
||||
})
|
||||
|
||||
// ── Staff roles ────────────────────────────────────────────────────────────
|
||||
|
||||
test('staff roles parse from the comma-separated ids a person actually pastes', () => {
|
||||
const { roles, invalid } = parseStaffRoles(' 123456789012345678 , 987654321098765432 ')
|
||||
assert.deepEqual(roles, ['123456789012345678', '987654321098765432'])
|
||||
assert.deepEqual(invalid, [])
|
||||
})
|
||||
|
||||
test('a typo is REPORTED, never quietly dropped', () => {
|
||||
const { invalid } = parseStaffRoles('123456789012345678, @Moderators')
|
||||
assert.deepEqual(invalid, ['@Moderators'])
|
||||
})
|
||||
|
||||
test('an empty field is a legitimate answer and not an error', () => {
|
||||
const { roles, invalid } = parseStaffRoles('')
|
||||
assert.deepEqual(roles, [])
|
||||
assert.deepEqual(invalid, [])
|
||||
})
|
||||
|
||||
test('roles round-trip through the field', () => {
|
||||
const { roles } = parseStaffRoles(formatStaffRoles(['111111111111111111', '222222222222222222']))
|
||||
assert.deepEqual(roles, ['111111111111111111', '222222222222222222'])
|
||||
})
|
||||
|
||||
// ── The status line ────────────────────────────────────────────────────────
|
||||
|
||||
test('off with channels still standing says so — the surprising case', () => {
|
||||
const text = statusSummary({ enabled: false }, [{ channelRef: '900' }, { channelRef: '901' }])
|
||||
assert.match(text, /^Off\./)
|
||||
assert.match(text, /2 channels remain/)
|
||||
})
|
||||
|
||||
test('off with nothing provisioned does not invent a warning', () => {
|
||||
const text = statusSummary({ enabled: false }, [])
|
||||
assert.match(text, /No channels are provisioned/)
|
||||
})
|
||||
|
||||
test('on states the threshold in the words the setting uses', () => {
|
||||
const text = statusSummary({ enabled: true, minMembers: 5 }, [{ channelRef: '900' }])
|
||||
assert.match(text, /at least 5 members/)
|
||||
assert.match(text, /1 provisioned/)
|
||||
})
|
||||
|
||||
test('a threshold of one is not pluralised', () => {
|
||||
assert.match(statusSummary({ enabled: true, minMembers: 1 }, []), /at least 1 member get/)
|
||||
})
|
||||
@@ -14,7 +14,8 @@
|
||||
"seed": "npm run seed --prefix server",
|
||||
"build": "npm run build --prefix client",
|
||||
"start": "npm start --prefix server",
|
||||
"check:modules": "node scripts/checkModuleIdentifiers.js"
|
||||
"check:modules": "node scripts/checkModuleIdentifiers.js",
|
||||
"check:hosts": "node scripts/checkNoExternalHosts.js"
|
||||
},
|
||||
"keywords": ["express", "mariadb", "react", "vite", "jwt"],
|
||||
"author": "whitlocktech",
|
||||
|
||||
192
scripts/checkNoExternalHosts.js
Normal file
192
scripts/checkNoExternalHosts.js
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §3.2 rule 4 — no phone-home in the engagement subsystem ────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §3.2 records a posture the codebase already has and this check
|
||||
// exists to keep: **no transport may ship a default host, endpoint, API base or
|
||||
// sender.** A transport with no operator configuration is `unconfigured` and its
|
||||
// channel is off — it never quietly falls back to a destination we chose.
|
||||
//
|
||||
// The rule is easy to hold and easy to break by accident, and the removed Gmail
|
||||
// transport is the proof of both: `smtp.gmail.com` and port 465 were literals in
|
||||
// `mailer.buildTransport()`, which made "which provider" a code edit and made the
|
||||
// deployment's mail depend on a host nobody configured. Deleting that literal is
|
||||
// what this check was written against, and it is the first thing it would have
|
||||
// caught.
|
||||
//
|
||||
// **It reads code, not prose.** A comment naming `smtp.gmail.com` as the
|
||||
// migration path for existing operators is exactly the documentation this phase
|
||||
// owes, and a check that forbade it would teach people to phrase around it. So
|
||||
// comments and the insides of ordinary strings are masked out; what is checked is
|
||||
// a HOSTNAME OR URL appearing as a string literal in the engagement trees. Same
|
||||
// design, and the same reasoning, as `checkModuleIdentifiers.js` — including
|
||||
// having its own test suite, because a check that silently stops checking is
|
||||
// worse than no check.
|
||||
//
|
||||
// Scope is the engagement subsystem plus the mail path it owns, not the whole
|
||||
// server: core legitimately talks to hosts an operator configured elsewhere
|
||||
// (ntfy, Discord, the sidecar), and those are not this rule's business.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
// The trees the rule covers. `server/src/engagement/` is where transports and,
|
||||
// later, the rules engine live; `utils/mailer.js` is the one file outside it that
|
||||
// composes and sends mail.
|
||||
const TREES = [path.join(ROOT, 'server', 'src', 'engagement')]
|
||||
const FILES = [path.join(ROOT, 'server', 'src', 'utils', 'mailer.js')]
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
|
||||
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs'])
|
||||
|
||||
// A URL, or a bare dotted hostname with a real TLD. The TLD length floor is what
|
||||
// keeps `emailConfig.model` and `foo.js` out of it — a two-plus-letter final
|
||||
// label after at least one dot, with no path characters, is a host.
|
||||
// The `(?![-\w])` after the TLD is not redundant with `\b`: `\b` matches between
|
||||
// `l` and `-`, so `auth.email-verify` — an engagement TEMPLATE KEY, and one the
|
||||
// plan names (§4.6.1) — was read as the host `auth.email` with a stray suffix.
|
||||
// A real hostname's TLD is the last label, so a `-` or a word character following
|
||||
// it means the match is a truncation of a longer identifier rather than a
|
||||
// destination. Everything a host IS followed by (a quote, `/`, `:`, `?`) still
|
||||
// matches.
|
||||
const URL_LITERAL = /\b(?:https?|smtps?):\/\/[^\s'"`]+/
|
||||
const HOSTNAME_LITERAL = /\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|co|email|mail|cloud|app|us|eu)(?![-\w])/i
|
||||
|
||||
// Hosts that are not destinations: the loopback family, and the RFC 2606 names
|
||||
// reserved for documentation. A placeholder in an admin form's help text is the
|
||||
// opposite of a phone-home — it shows the operator the SHAPE of a value they
|
||||
// must supply, and blanking it would make the form worse to hold the rule.
|
||||
const ALLOWED = [
|
||||
/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)$/i,
|
||||
/(?:^|\.)example\.(?:com|net|org)$/i,
|
||||
/(?:^|\.)(?:invalid|test|localhost)$/i,
|
||||
]
|
||||
|
||||
const isAllowed = (host) => ALLOWED.some((re) => re.test(host))
|
||||
|
||||
const hostOf = (literal) => {
|
||||
const withoutScheme = literal.replace(/^[a-z]+:\/\//i, '')
|
||||
return withoutScheme.split(/[/?#:]/)[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank comments and mask string bodies in one left-to-right pass, keeping every
|
||||
* offset aligned so reported line numbers stay honest.
|
||||
*
|
||||
* Lifted from `checkModuleIdentifiers.maskCode` deliberately rather than
|
||||
* imported: that file's masking is tuned to ITS four checks (it keeps quotes so a
|
||||
* route-path check can re-read the original at the same offsets), and coupling
|
||||
* two checks through a shared helper means a change made for one silently
|
||||
* re-scopes the other. Both are ~40 lines and both are tested.
|
||||
*/
|
||||
function maskComments(src) {
|
||||
const out = Array.from(src)
|
||||
const blank = (from, to) => {
|
||||
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
|
||||
}
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const c = src[i]
|
||||
const next = src[i + 1]
|
||||
if (c === '/' && next === '/') {
|
||||
let j = i
|
||||
while (j < src.length && src[j] !== '\n') j++
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (c === '/' && next === '*') {
|
||||
const end = src.indexOf('*/', i + 2)
|
||||
const j = end === -1 ? src.length : end + 2
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'" || c === '`') {
|
||||
let j = i + 1
|
||||
while (j < src.length) {
|
||||
if (src[j] === '\\') { j += 2; continue }
|
||||
if (src[j] === c) break
|
||||
j++
|
||||
}
|
||||
// Keep the string body: it is what this check reads. Only the delimiters
|
||||
// matter for finding it, and comments are what has to go.
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
// Every string literal in the (comment-free) source, with its line number.
|
||||
const STRING = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g
|
||||
|
||||
function lineOf(src, index) {
|
||||
return src.slice(0, index).split('\n').length
|
||||
}
|
||||
|
||||
/** Check one file's contents. Returns [{ file, line, literal, host }]. */
|
||||
function checkFile(rel, src) {
|
||||
const hits = []
|
||||
const code = maskComments(src)
|
||||
for (const m of code.matchAll(STRING)) {
|
||||
const value = m[2]
|
||||
if (!value) continue
|
||||
const urlMatch = value.match(URL_LITERAL)
|
||||
const hostMatch = urlMatch ? null : value.match(HOSTNAME_LITERAL)
|
||||
const literal = urlMatch ? urlMatch[0] : hostMatch ? hostMatch[0] : null
|
||||
if (!literal) continue
|
||||
const host = hostOf(literal)
|
||||
if (isAllowed(host)) continue
|
||||
hits.push({ file: rel, line: lineOf(src, m.index), literal, host })
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(full, out)
|
||||
else out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function run() {
|
||||
const files = [...TREES.flatMap((t) => walk(t)), ...FILES.filter((f) => fs.existsSync(f))]
|
||||
const hits = []
|
||||
for (const file of files) {
|
||||
if (!CODE.has(path.extname(file))) continue
|
||||
const rel = path.relative(ROOT, file).split(path.sep).join('/')
|
||||
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
module.exports = { run, checkFile, maskComments, isAllowed, hostOf }
|
||||
|
||||
if (require.main === module) {
|
||||
const hits = run()
|
||||
if (hits.length === 0) {
|
||||
console.log('OK — the engagement subsystem names no external host (ENGAGEMENT.md §3.2 rule 4).')
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(
|
||||
`\nThe engagement subsystem names ${hits.length} external host${hits.length === 1 ? '' : 's'} ` +
|
||||
'in code (ENGAGEMENT.md §3.2 rule 4). A destination belongs in operator-supplied ' +
|
||||
'configuration, never in a literal:\n',
|
||||
)
|
||||
for (const h of hits) {
|
||||
console.error(` ${h.file}:${h.line} "${h.literal}"`)
|
||||
}
|
||||
console.error(
|
||||
'\nIf this is help text or documentation rather than a destination, put it in a comment or ' +
|
||||
'use an example.com placeholder — the check masks comments and allows the reserved ' +
|
||||
'documentation names on purpose.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -80,10 +80,12 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
|
||||
# It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. The contact recipient is the `contact_email` site
|
||||
# Email is configured in Admin → Settings → Email, not here: pick a mail
|
||||
# transport (SMTP) and enter its host, port and credentials, stored encrypted in
|
||||
# the DB. A relay is the recommended posture; smtp.gmail.com:587 with an app
|
||||
# password is the simplest. The contact recipient is the `contact_email` site
|
||||
# setting; while email is unconfigured the contact form falls back to a mailto: link.
|
||||
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
|
||||
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
|
||||
1159
server/db/schema.sql
1159
server/db/schema.sql
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ const settingsDb = require('../src/model/settings/settings.db')
|
||||
const wikiDb = require('../src/model/wiki/wiki.db')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const { ensureSchema, close } = require('../src/utils/db')
|
||||
const { seedTemplates } = require('../src/engagement/templates')
|
||||
const { seedCoreRules } = require('../src/engagement/coreRules')
|
||||
const brand = require('../src/config/brand')
|
||||
|
||||
const log = require('../src/utils/logger')('seed')
|
||||
@@ -74,6 +76,19 @@ async function seedDefaults() {
|
||||
// migration of pages seeded before the wiki upgrade).
|
||||
await wikiDb.assignCategoryBySlug(slug, categorySlug)
|
||||
}
|
||||
// The shipped mail bodies (ENGAGEMENT.md §4.6.1). Idempotent, and it never
|
||||
// overwrites a row an operator has edited — `customized = 1` is checked in the
|
||||
// UPDATE's own WHERE, not in a read-then-write. Never throws: a template that
|
||||
// failed to seed costs the shipped default, which `renderByKey` falls back to
|
||||
// anyway, and must not stop a boot.
|
||||
await seedTemplates()
|
||||
// Core's five rules — the four Team ones (Phase 6) and news (Phase 11) —
|
||||
// seeded ONCE and all disabled. Each GROUP carries its own settings-key guard
|
||||
// rather than re-ensured, so a rule an operator deleted stays deleted and one
|
||||
// they enabled stays enabled; and so the news rule reaches the deployments that
|
||||
// were already stamped for Teams, which are exactly the ones that lose their
|
||||
// raw news push to the engine (ENGAGEMENT.md §7.1 Q9).
|
||||
await seedCoreRules()
|
||||
log.info('settings and wiki defaults ensured')
|
||||
}
|
||||
|
||||
|
||||
211
server/engagement-triggers.json
Normal file
211
server/engagement-triggers.json
Normal file
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.8.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "news.post",
|
||||
"owner": "core",
|
||||
"label": "News post published",
|
||||
"description": "A news / Five-on-Friday / newsletter post was published.",
|
||||
"kind": "event",
|
||||
"subjectKey": null,
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Five on Friday — the Yew invasion",
|
||||
"description": "The post title."
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Four new champion spawns, and the fate of the Yew moongate…",
|
||||
"description": "A plain-text summary, already stripped of markup."
|
||||
},
|
||||
{
|
||||
"name": "category",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Five on Friday",
|
||||
"description": "The post category, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": true,
|
||||
"example": "/site/news",
|
||||
"description": "Site-relative path to the post. The news list today — the site has no per-post route."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.announcement",
|
||||
"owner": "core",
|
||||
"label": "Team — announcement",
|
||||
"description": "A leader posted an announcement in a Team.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "authorName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Marisol",
|
||||
"description": "Display name of the leader who posted."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Siege practice moved to Sunday",
|
||||
"description": "The announcement title."
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "We are moving practice to Sunday 8pm…",
|
||||
"description": "Plain-text excerpt of the announcement body."
|
||||
},
|
||||
{
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil/forum/419",
|
||||
"description": "Site-relative path to the announcement."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.forum.post",
|
||||
"owner": "core",
|
||||
"label": "Team — new forum post",
|
||||
"description": "A new thread or reply in a Team forum.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "authorName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Darrow",
|
||||
"description": "Display name of the poster."
|
||||
},
|
||||
{
|
||||
"name": "threadTitle",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Tuesday champ rotation",
|
||||
"description": "Title of the thread the post belongs to."
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Moving the Tuesday run an hour later…",
|
||||
"description": "Plain-text excerpt of the post body, already stripped of markup."
|
||||
},
|
||||
{
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil/forum/412",
|
||||
"description": "Site-relative path to the post."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.leadership.changed",
|
||||
"owner": "core",
|
||||
"label": "Team — leadership change",
|
||||
"description": "Leadership changed in a Team.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "leaderName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Marisol",
|
||||
"description": "Display name of the new leader."
|
||||
},
|
||||
{
|
||||
"name": "teamUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil",
|
||||
"description": "Site-relative path to the Team page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.member.joined",
|
||||
"owner": "core",
|
||||
"label": "Team — new member",
|
||||
"description": "Someone joined a Team.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "memberName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Darrow",
|
||||
"description": "Display name of the member who joined."
|
||||
},
|
||||
{
|
||||
"name": "teamUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil",
|
||||
"description": "Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"seed": "node db/seed.js",
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"engagement:manifest": "node scripts/engagementManifest.js",
|
||||
"test": "node --test --require ./test/_setup.js"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,30 +17,6 @@
|
||||
"method": "GET",
|
||||
"path": "/api/health"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/activity"
|
||||
@@ -89,14 +65,6 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/email/config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/callback"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/start"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/disconnect"
|
||||
@@ -105,6 +73,106 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/test"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/audience-preview"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/audiences"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/channels"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/rules"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/rules/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/rules/:id"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/engagement/rules/:id/enabled"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/segments"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/segments"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/segments/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/segments/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/sends"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/templates/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/templates/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/duplicate"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/preview"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/test-send"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites"
|
||||
@@ -145,6 +213,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/recent"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/reports"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/reports/:id/handle"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/search"
|
||||
@@ -293,6 +369,98 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/site-mode"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/archive"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/display-name"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/forum/moderation"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/grants"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/hide"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/leader-override"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/unhide"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/settings"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/uploads"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/integrations"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/integrations"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/integrations/:teamId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/requests"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/requests/:id/decide"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/resync"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/review"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/voice"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/teams/voice"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/teams/voice/:teamId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/voice/sync"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uploads"
|
||||
@@ -333,6 +501,14 @@
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki"
|
||||
@@ -389,6 +565,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/tags"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/email/verify/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/email/verify/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/invite/:token"
|
||||
@@ -417,6 +601,18 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/email"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/email/pending"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/email/resend"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account/identities"
|
||||
@@ -465,6 +661,26 @@
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/devices/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/notifications/:id/read"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/channels"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/channels"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/notifications/read-all"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/streams"
|
||||
@@ -477,6 +693,18 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/teams"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/unread-count"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/sessions"
|
||||
@@ -557,38 +785,6 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/sso/totp"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/password"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/username"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals"
|
||||
@@ -605,10 +801,78 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals/eligible"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/access"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/teams/:slug/forum/posts/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/report"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/grants"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/grants"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/grants/:userId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/engagement/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/engagement/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules"
|
||||
@@ -637,6 +901,34 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/status"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/:slug/activity"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/:slug/members"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/version"
|
||||
@@ -674,6 +966,14 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/bot-config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/commands"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/internal/commands/dispatch"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
132
server/scripts/engagementManifest.js
Normal file
132
server/scripts/engagementManifest.js
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Engagement trigger manifest — the machine-readable freeze of core's event
|
||||
* contract (ENGAGEMENT.md §4.3, property 4).
|
||||
*
|
||||
* Why this exists: a trigger declaration is what a template interpolates and what
|
||||
* a rule is written against. Renaming a variable, changing its type, or widening
|
||||
* a ceiling breaks stored templates and stored rules — and does it silently, at
|
||||
* send time, in an email someone already received. `routes.manifest.json` freezes
|
||||
* the URL surface for exactly this reason and this is its twin: a generated
|
||||
* artifact committed to the repo, whose DIFF is the review signal. Changing a
|
||||
* declaration without regenerating is a red build; changing one deliberately puts
|
||||
* the change in front of a reviewer instead of letting it pass as a comment edit.
|
||||
*
|
||||
* **Core's only.** A module ships its own `engagement-triggers.json` in its
|
||||
* bundle, for the same reason it ships a prebuilt swagger fragment: core never
|
||||
* has its sources to analyse (MODULE_API.md §6.1a). So this loads
|
||||
* `config/coreTriggers.js` through the real `registerCore()` — the declarations
|
||||
* as VALIDATED, not as authored — which means a shape error is a failure here
|
||||
* rather than a surprise at boot.
|
||||
*
|
||||
* The `resolve` half of an audience cannot be frozen (it is a function over a
|
||||
* module's own store), so audiences are deliberately absent: what a manifest can
|
||||
* usefully freeze is the payload contract, and freezing half a declaration would
|
||||
* suggest the other half was checked.
|
||||
*
|
||||
* Usage:
|
||||
* npm run engagement:manifest # write server/engagement-triggers.json
|
||||
* npm run engagement:manifest -- --check # exit 1 if the committed file is stale
|
||||
*/
|
||||
|
||||
// registries.js -> config/coreStreams + utils/discordAnnounce, which reach
|
||||
// utils/db and build a mariadb pool at require time. Point it at a closed port
|
||||
// (the same trick routeManifest.js and the test suite use) so generating a
|
||||
// manifest never opens a connection or hangs on a missing database.
|
||||
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const db = require('../src/utils/db')
|
||||
const { MODULE_API_VERSION } = require('../src/modules/version')
|
||||
|
||||
const SERVER_ROOT = path.join(__dirname, '..')
|
||||
const MANIFEST_PATH = path.join(SERVER_ROOT, 'engagement-triggers.json')
|
||||
|
||||
const MANIFEST_COMMENT =
|
||||
'Generated event-trigger inventory - the authoritative freeze of CORE\'s engagement ' +
|
||||
'contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` ' +
|
||||
'in website/server. A renamed variable, a changed type or a widened ceiling breaks stored ' +
|
||||
'templates and rules, so the diff here is the review signal. A module ships its own copy ' +
|
||||
'in its bundle; this file never contains one.'
|
||||
|
||||
function build() {
|
||||
// Through registerCore(), not by reading the array: what a reviewer needs
|
||||
// frozen is what the registry ACCEPTED — defaults filled in, audience resolved
|
||||
// against the ceiling, variables normalised — because that is what the editor
|
||||
// will read and the emit path will check against.
|
||||
registries.registerCore()
|
||||
|
||||
const triggers = registries
|
||||
.allTriggers()
|
||||
.filter((t) => t.owner === 'core')
|
||||
// Sorted by id rather than left in registration order, like the route
|
||||
// manifest: reordering a declaration in the source is not a contract change
|
||||
// and must not produce a diff that looks like one.
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
owner: t.owner,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
kind: t.kind,
|
||||
subjectKey: t.subjectKey,
|
||||
audience: t.audience,
|
||||
ceiling: t.ceiling,
|
||||
version: t.version,
|
||||
// Variables keep their DECLARED order. Here it is contract: it is the
|
||||
// order the template editor lists them in, and an author reading the
|
||||
// manifest should see what the editor will show.
|
||||
variables: t.variables.map((v) => ({
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
required: v.required,
|
||||
example: v.example,
|
||||
description: v.description,
|
||||
})),
|
||||
}))
|
||||
|
||||
return {
|
||||
_comment: MANIFEST_COMMENT,
|
||||
// The contract version these declarations are shaped by. A reader looking at
|
||||
// a stale manifest needs to know which API's rules produced it.
|
||||
moduleApiVersion: MODULE_API_VERSION,
|
||||
triggers,
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const check = process.argv.includes('--check')
|
||||
const next = `${JSON.stringify(build(), null, 2)}\n`
|
||||
|
||||
if (!check) {
|
||||
fs.writeFileSync(MANIFEST_PATH, next)
|
||||
process.stdout.write(`wrote ${path.relative(SERVER_ROOT, MANIFEST_PATH)}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
|
||||
if (current === next) {
|
||||
process.stdout.write('engagement-triggers.json is current\n')
|
||||
return
|
||||
}
|
||||
process.stderr.write(
|
||||
'engagement-triggers.json is stale.\n' +
|
||||
'A trigger declaration changed without the manifest being regenerated.\n' +
|
||||
'Run `npm run engagement:manifest` in website/server and commit the result —\n' +
|
||||
'the diff is what a reviewer reads to see the contract change.\n',
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
// The mariadb pool never connects here, but it keeps the loop alive even
|
||||
// pointed at a dead port — the same exit routeManifest.js takes.
|
||||
db.close().finally(() => process.exit(process.exitCode || 0))
|
||||
}
|
||||
|
||||
module.exports = { build }
|
||||
@@ -192,6 +192,15 @@ app.use('/api', apiRouter)
|
||||
// module's collision checks are asked against what is ALREADY registered, so
|
||||
// core's streams, its announce leg and its extension-slot fill have to be there
|
||||
// before the first module registers anything (MODULE_SYSTEM.md §1.8).
|
||||
// The engagement subsystem's own door, which is what brings core's mail
|
||||
// transports and its three delivery channels into existence (ENGAGEMENT.md
|
||||
// §3.1). Requiring `engagement/channels` or `engagement/transports` directly gets
|
||||
// the empty registry — populating it is deliberately a side effect of this one
|
||||
// require, so there is exactly one place either can be registered from. It runs
|
||||
// beside registerCore() and before the loader for the same reason: a preference
|
||||
// read or a mail send must never find a half-populated registry.
|
||||
require('./engagement')
|
||||
|
||||
registries.registerCore()
|
||||
modules.load({
|
||||
public: require('./router/v1/public'),
|
||||
|
||||
@@ -37,7 +37,7 @@ class BaseProvider {
|
||||
}
|
||||
|
||||
// Complete an SSO redirect flow: exchange the callback code for a normalized
|
||||
// user profile ({ subject, email, name }).
|
||||
// user profile ({ subject, email, emailVerified, name }).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async handleCallback(params) {
|
||||
throw new Error(`handleCallback() not implemented for provider '${this.id}'`)
|
||||
@@ -49,7 +49,7 @@ class BaseProvider {
|
||||
throw new Error(`getUserProfile() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Normalize a raw external profile to { subject, email, name }.
|
||||
// Normalize a raw external profile to { subject, email, emailVerified, name }.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
mapUser(profile) {
|
||||
throw new Error(`mapUser() not implemented for provider '${this.id}'`)
|
||||
|
||||
@@ -23,7 +23,14 @@ class DiscordProvider extends OAuth2Provider {
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
// global_name is the new display name; fall back to the legacy username.
|
||||
return { subject: p.id, email: p.email || null, name: p.global_name || p.username || null }
|
||||
return {
|
||||
subject: p.id,
|
||||
email: p.email || null,
|
||||
// Discord spells the claim `verified` rather than `email_verified`, and it
|
||||
// means exactly this: the user confirmed the address with Discord.
|
||||
emailVerified: p.verified === true,
|
||||
name: p.global_name || p.username || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ class GenericOidcProvider extends OAuth2Provider {
|
||||
return {
|
||||
subject: p.sub || p.id || p.user_id || p.uid || null,
|
||||
email: p.email || null,
|
||||
// The standard OIDC claim. An IdP that omits it has not asserted anything,
|
||||
// so the address stays unverified and the user proves it the ordinary way —
|
||||
// absent is treated as false, never as true.
|
||||
emailVerified: p.email_verified === true || p.email_verified === 'true',
|
||||
name: p.name || p.preferred_username || p.username || p.email || null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@ class GoogleProvider extends OAuth2Provider {
|
||||
return { access_type: 'online', prompt: 'select_account' }
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
return { subject: p.sub, email: p.email || null, name: p.name || p.email || null }
|
||||
return {
|
||||
subject: p.sub,
|
||||
email: p.email || null,
|
||||
// Google's OIDC userinfo carries the standard `email_verified` claim. Read
|
||||
// it rather than inferring verification from the mere presence of an
|
||||
// address, which is what this code used to do (ENGAGEMENT.md §0.6/1b).
|
||||
emailVerified: p.email_verified === true || p.email_verified === 'true',
|
||||
name: p.name || p.email || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,44 @@ async function requireAuth(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode.
|
||||
//
|
||||
// For a PUBLIC route whose content — not merely its presentation — depends on who
|
||||
// is asking. The Team activity feed is the first: `public` items go to everyone
|
||||
// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so
|
||||
// an anonymous caller must be served, not rejected, and an authenticated one must
|
||||
// be identified properly.
|
||||
//
|
||||
// "Properly" is why this is not attachSession. That one decodes the token and
|
||||
// stops, which is right for reading back your own session but wrong here: a
|
||||
// banned account, a password change, or a logout would all keep working against
|
||||
// the private half of the feed until the JWT expired. This runs the same
|
||||
// database re-validation requireAuth does — status, cutoff, revocation — and on
|
||||
// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is
|
||||
// no longer good sees the public feed, which is exactly what they are entitled to.
|
||||
//
|
||||
// A database error also degrades to anonymous. On a public route the safe
|
||||
// direction is to serve less, and 500ing a page because a session lookup failed
|
||||
// would take the whole Team page down for callers who never sent a token.
|
||||
async function optionalAuth(req, res, next) {
|
||||
const session = sessionService.validateSession(req)
|
||||
if (!session) return next()
|
||||
try {
|
||||
const user = await users.getById(session.userId)
|
||||
if (!user) return next()
|
||||
if (user.status && user.status !== 'active') return next()
|
||||
if (isBeforeCutoff(session, user.tokens_valid_after)) return next()
|
||||
if (await sessionService.isSessionRevoked(session.sessionId)) return next()
|
||||
|
||||
req.user = user
|
||||
req.session = session
|
||||
req.authMethod = session.authMethod
|
||||
} catch (err) {
|
||||
log.warn('optionalAuth: continuing anonymously', { message: err.message })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
|
||||
// first so req.user is populated. Use for admin-only endpoints (users, site
|
||||
// mode, settings) so a lower-privilege editor cannot reach them.
|
||||
@@ -91,6 +129,7 @@ function requireRole(...roles) {
|
||||
|
||||
module.exports = {
|
||||
attachSession,
|
||||
optionalAuth,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
}
|
||||
|
||||
@@ -4,44 +4,58 @@
|
||||
// normalizer (e.g. rich_text runs its html through the allowlist), stamping the
|
||||
// registry `version`, defaulting `visible` to true, and recursing one level into
|
||||
// container slots. Returns a new array; never mutates the input.
|
||||
//
|
||||
// Parameterized by a registry lookup for the same reason validateBlocks is
|
||||
// (engagement Phase 5a): the `email.*` family is a separate registry and must get
|
||||
// the same validate-then-sanitize order, not a second implementation of it.
|
||||
|
||||
const { getBlock } = require('./registry')
|
||||
|
||||
function sanitizeBlocks(blocks) {
|
||||
if (!Array.isArray(blocks)) return []
|
||||
return blocks.map(sanitizeOne)
|
||||
}
|
||||
/**
|
||||
* Build a blocks sanitizer bound to one registry.
|
||||
* @param {(type: string) => object|null} lookup registry `getBlock`
|
||||
* @returns {(blocks: unknown) => object[]}
|
||||
*/
|
||||
function makeSanitizeBlocks(lookup) {
|
||||
function sanitizeOne(block) {
|
||||
const def = lookup(block.type)
|
||||
if (!def) return block // unreachable after validation, but stay defensive
|
||||
|
||||
function sanitizeOne(block) {
|
||||
const def = getBlock(block.type)
|
||||
if (!def) return block // unreachable after validation, but stay defensive
|
||||
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
|
||||
|
||||
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
|
||||
// Recurse into container slots first (leaf sub-blocks get sanitized too).
|
||||
if (def.container) {
|
||||
for (const slot of def.containerSlots) {
|
||||
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into container slots first (leaf sub-blocks get sanitized too).
|
||||
if (def.container) {
|
||||
for (const slot of def.containerSlots) {
|
||||
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
|
||||
// Apply the block's own normalizer last (operates on its scalar props).
|
||||
if (def.sanitize) {
|
||||
try {
|
||||
props = def.sanitize(props)
|
||||
} catch {
|
||||
// Leave props as-is; validation already passed, a sanitize throw shouldn't
|
||||
// block the save.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
version: Number.isInteger(block.version) ? block.version : def.version,
|
||||
visible: block.visible !== false,
|
||||
props,
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the block's own normalizer last (operates on its scalar props).
|
||||
if (def.sanitize) {
|
||||
try {
|
||||
props = def.sanitize(props)
|
||||
} catch {
|
||||
// Leave props as-is; validation already passed, a sanitize throw shouldn't
|
||||
// block the save.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
version: Number.isInteger(block.version) ? block.version : def.version,
|
||||
visible: block.visible !== false,
|
||||
props,
|
||||
return function sanitizeBlocks(blocks) {
|
||||
if (!Array.isArray(blocks)) return []
|
||||
return blocks.map(sanitizeOne)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sanitizeBlocks }
|
||||
// The page-registry binding — the export every existing caller already uses.
|
||||
const sanitizeBlocks = makeSanitizeBlocks(getBlock)
|
||||
|
||||
module.exports = { sanitizeBlocks, makeSanitizeBlocks }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Server-side validation for a page's `blocks` array, run on every save before
|
||||
// Server-side validation for a stored `blocks` array, run on every save before
|
||||
// persisting. The admin UI validates client-side too, but that can be bypassed
|
||||
// by a direct API call, so this is the authoritative gate: it enforces the block
|
||||
// envelope (reserved keys only), that every `type` is a registered block, that
|
||||
@@ -9,6 +9,14 @@
|
||||
// Returns { valid, errors } — a flat list of human-readable error strings, each
|
||||
// prefixed with the path to the offending block (e.g. `blocks[2].props.text`).
|
||||
// It never throws on bad input; callers turn a non-empty `errors` into a 400.
|
||||
//
|
||||
// **The walk is parameterized by a registry lookup, and the page registry is one
|
||||
// binding of it** (engagement Phase 5a). The `email.*` family is a SEPARATE
|
||||
// registry — its entries carry renderers instead of a cache policy, and a
|
||||
// CMS page must not validate with an email block inside it — but the envelope,
|
||||
// the id uniqueness, the schema dispatch and the nesting cap are the same rules
|
||||
// for both. Sharing the walk is what keeps them the same rules rather than two
|
||||
// copies that drift.
|
||||
|
||||
const { getBlock, RESERVED_KEYS } = require('./registry')
|
||||
|
||||
@@ -18,114 +26,129 @@ const MAX_SUBBLOCKS = 50 // sub-blocks per container slot
|
||||
const ID_RE = /^[A-Za-z0-9_-]{1,40}$/
|
||||
|
||||
/**
|
||||
* Validate a stored blocks array against the registry.
|
||||
* @param {unknown} blocks
|
||||
* @returns {{ valid: boolean, errors: string[] }}
|
||||
* Build a blocks validator bound to one registry.
|
||||
*
|
||||
* @param {(type: string) => object|null} lookup registry `getBlock`
|
||||
* @param {{ maxBlocks?: number, maxSubBlocks?: number }} [limits]
|
||||
* @returns {(blocks: unknown) => { valid: boolean, errors: string[] }}
|
||||
*/
|
||||
function validateBlocks(blocks) {
|
||||
const errors = []
|
||||
if (!Array.isArray(blocks)) {
|
||||
return { valid: false, errors: ['blocks must be an array'] }
|
||||
}
|
||||
if (blocks.length > MAX_BLOCKS) {
|
||||
errors.push(`blocks may not exceed ${MAX_BLOCKS} top-level entries`)
|
||||
}
|
||||
const seenIds = new Set()
|
||||
blocks.forEach((block, i) => {
|
||||
validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false })
|
||||
})
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
function makeValidateBlocks(lookup, limits = {}) {
|
||||
const maxBlocks = limits.maxBlocks || MAX_BLOCKS
|
||||
const maxSubBlocks = limits.maxSubBlocks || MAX_SUBBLOCKS
|
||||
|
||||
// Envelope: only the reserved keys, nothing smuggled at the top level.
|
||||
function checkEnvelope(block, path, errors) {
|
||||
for (const key of Object.keys(block)) {
|
||||
if (!RESERVED_KEYS.includes(key)) {
|
||||
errors.push(`${path}.${key} is not an allowed top-level key`)
|
||||
// Envelope: only the reserved keys, nothing smuggled at the top level.
|
||||
function checkEnvelope(block, path, errors) {
|
||||
for (const key of Object.keys(block)) {
|
||||
if (!RESERVED_KEYS.includes(key)) {
|
||||
errors.push(`${path}.${key} is not an allowed top-level key`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// id — stable, unique across the whole page (top-level and nested share one
|
||||
// namespace since ids are the future join point for revision history).
|
||||
function checkId(block, path, seenIds, errors) {
|
||||
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
|
||||
errors.push(`${path}.id must be a short id string`)
|
||||
} else if (seenIds.has(block.id)) {
|
||||
errors.push(`${path}.id duplicates another block id (${block.id})`)
|
||||
} else {
|
||||
seenIds.add(block.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-block prop schema from the registry (skipped when props isn't an object —
|
||||
// that's already reported separately).
|
||||
function checkPropSchema(def, props, path, errors) {
|
||||
if (!def.schema || !props || typeof props !== 'object') return
|
||||
let schemaErrors = []
|
||||
try {
|
||||
schemaErrors = def.schema(props) || []
|
||||
} catch (err) {
|
||||
schemaErrors = [`schema threw: ${err.message}`]
|
||||
}
|
||||
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
|
||||
}
|
||||
|
||||
// Nesting: only container blocks may hold sub-blocks, capped at one level.
|
||||
function checkNesting(def, props, path, seenIds, errors, nested) {
|
||||
if (nested) {
|
||||
errors.push(`${path} is a container and may not be nested inside another container`)
|
||||
return
|
||||
}
|
||||
for (const slot of def.containerSlots) {
|
||||
const sub = props ? props[slot] : undefined
|
||||
if (sub === undefined) continue // an empty slot is allowed
|
||||
if (!Array.isArray(sub)) {
|
||||
errors.push(`${path}.props.${slot} must be an array of blocks`)
|
||||
continue
|
||||
// id — stable, unique across the whole document (top-level and nested share one
|
||||
// namespace since ids are the future join point for revision history).
|
||||
function checkId(block, path, seenIds, errors) {
|
||||
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
|
||||
errors.push(`${path}.id must be a short id string`)
|
||||
} else if (seenIds.has(block.id)) {
|
||||
errors.push(`${path}.id duplicates another block id (${block.id})`)
|
||||
} else {
|
||||
seenIds.add(block.id)
|
||||
}
|
||||
if (sub.length > MAX_SUBBLOCKS) {
|
||||
errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`)
|
||||
}
|
||||
|
||||
// Per-block prop schema from the registry (skipped when props isn't an object —
|
||||
// that's already reported separately).
|
||||
function checkPropSchema(def, props, path, errors) {
|
||||
if (!def.schema || !props || typeof props !== 'object') return
|
||||
let schemaErrors = []
|
||||
try {
|
||||
schemaErrors = def.schema(props) || []
|
||||
} catch (err) {
|
||||
schemaErrors = [`schema threw: ${err.message}`]
|
||||
}
|
||||
sub.forEach((child, j) => {
|
||||
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
|
||||
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
|
||||
}
|
||||
|
||||
// Nesting: only container blocks may hold sub-blocks, capped at one level.
|
||||
function checkNesting(def, props, path, seenIds, errors, nested) {
|
||||
if (nested) {
|
||||
errors.push(`${path} is a container and may not be nested inside another container`)
|
||||
return
|
||||
}
|
||||
for (const slot of def.containerSlots) {
|
||||
const sub = props ? props[slot] : undefined
|
||||
if (sub === undefined) continue // an empty slot is allowed
|
||||
if (!Array.isArray(sub)) {
|
||||
errors.push(`${path}.props.${slot} must be an array of blocks`)
|
||||
continue
|
||||
}
|
||||
if (sub.length > maxSubBlocks) {
|
||||
errors.push(`${path}.props.${slot} may not exceed ${maxSubBlocks} blocks`)
|
||||
}
|
||||
sub.forEach((child, j) => {
|
||||
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one block envelope in place. `nested` = true when validating a
|
||||
* sub-block inside a container slot, which forbids further nesting.
|
||||
*/
|
||||
function validateBlock(block, path, seenIds, errors, { nested }) {
|
||||
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
|
||||
errors.push(`${path} must be an object`)
|
||||
return
|
||||
}
|
||||
|
||||
checkEnvelope(block, path, errors)
|
||||
checkId(block, path, seenIds, errors)
|
||||
|
||||
// visible — optional in input, but if present must be a boolean.
|
||||
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
|
||||
errors.push(`${path}.visible must be a boolean`)
|
||||
}
|
||||
|
||||
// props — always an object bag.
|
||||
const props = block.props
|
||||
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
|
||||
errors.push(`${path}.props must be an object`)
|
||||
}
|
||||
|
||||
// type — must resolve to a registered block.
|
||||
const def = typeof block.type === 'string' ? lookup(block.type) : null
|
||||
if (!def) {
|
||||
errors.push(`${path}.type is not a registered block type (${String(block.type)})`)
|
||||
return // can't validate props or nesting without a definition
|
||||
}
|
||||
|
||||
checkPropSchema(def, props, path, errors)
|
||||
if (def.container) checkNesting(def, props, path, seenIds, errors, nested)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a stored blocks array against the bound registry.
|
||||
* @param {unknown} blocks
|
||||
* @returns {{ valid: boolean, errors: string[] }}
|
||||
*/
|
||||
return function validateBlocks(blocks) {
|
||||
const errors = []
|
||||
if (!Array.isArray(blocks)) {
|
||||
return { valid: false, errors: ['blocks must be an array'] }
|
||||
}
|
||||
if (blocks.length > maxBlocks) {
|
||||
errors.push(`blocks may not exceed ${maxBlocks} top-level entries`)
|
||||
}
|
||||
const seenIds = new Set()
|
||||
blocks.forEach((block, i) => {
|
||||
validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false })
|
||||
})
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one block envelope in place. `nested` = true when validating a
|
||||
* sub-block inside a container slot, which forbids further nesting.
|
||||
*/
|
||||
function validateBlock(block, path, seenIds, errors, { nested }) {
|
||||
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
|
||||
errors.push(`${path} must be an object`)
|
||||
return
|
||||
}
|
||||
// The page-registry binding — the export every existing caller already uses.
|
||||
const validateBlocks = makeValidateBlocks(getBlock)
|
||||
|
||||
checkEnvelope(block, path, errors)
|
||||
checkId(block, path, seenIds, errors)
|
||||
|
||||
// visible — optional in input, but if present must be a boolean.
|
||||
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
|
||||
errors.push(`${path}.visible must be a boolean`)
|
||||
}
|
||||
|
||||
// props — always an object bag.
|
||||
const props = block.props
|
||||
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
|
||||
errors.push(`${path}.props must be an object`)
|
||||
}
|
||||
|
||||
// type — must resolve to a registered block.
|
||||
const def = typeof block.type === 'string' ? getBlock(block.type) : null
|
||||
if (!def) {
|
||||
errors.push(`${path}.type is not a registered block type (${String(block.type)})`)
|
||||
return // can't validate props or nesting without a definition
|
||||
}
|
||||
|
||||
checkPropSchema(def, props, path, errors)
|
||||
if (def.container) checkNesting(def, props, path, seenIds, errors, nested)
|
||||
}
|
||||
|
||||
module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }
|
||||
module.exports = { validateBlocks, makeValidateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
//
|
||||
// What is left of config/notificationStreams.js once the shard-derived catalog
|
||||
// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
|
||||
// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
|
||||
// produced by the website's own posts path, not by any game feed.
|
||||
// core, the CATALOG is content). `news.post` is produced by the website's own
|
||||
// posts path, not by any game feed, and the four `team.*` streams by core's own
|
||||
// Team sync and forum.
|
||||
//
|
||||
// Registered through modules/registries.js like any module's, and read back
|
||||
// through it — nothing imports this file to get "the catalog", because the
|
||||
// catalog is core's plus every module's.
|
||||
//
|
||||
// Phase 6 added the four Team streams below. They are core's for the same reason
|
||||
// the Team tables are: a module supplies who is in a Team, but who may be told
|
||||
// about it is the access resolver's answer, and that is core's (TEAMS.md Part 6).
|
||||
//
|
||||
// The payload that ever leaves the server is a CONTENT-FREE tickle
|
||||
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
|
||||
// over the authenticated API (docs/android/PLAN.md §11).
|
||||
@@ -21,6 +26,55 @@ const STREAMS = [
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
// ── Teams (TEAMS.md §6.2, phase 6) ───────────────────────────────────────
|
||||
//
|
||||
// FOUR streams, and not one per Team. The catalog is a static registration
|
||||
// validated at boot; it has no way to express an unbounded runtime-created set,
|
||||
// and a stream id per Team would leave rows in notification_subscriptions to
|
||||
// collect every time a Team archived. Which Team an event came from lives in
|
||||
// the RECIPIENT SET (utils/teamNotify.js) and in the `ref`, never in the id.
|
||||
//
|
||||
// `requiresLinkedAccount: false` on all four is deliberate and reads oddly.
|
||||
// These are game-sourced events, so the instinct is to demand a linked game
|
||||
// account — but a forum-granted user with no game identity at all is exactly
|
||||
// the population §2.5 path 3 exists for, and they are a legitimate recipient of
|
||||
// `team.forum.post`. The flag would refuse them a toggle they have every right
|
||||
// to. What enforces who gets what is the recipient computation, which asks the
|
||||
// access resolver; the stream flag is not a second, weaker copy of that rule.
|
||||
//
|
||||
// `personal: false` for the same reason it is false on news.post: these are not
|
||||
// owner-keyed events about one account's own property. `publishToUsers` is a
|
||||
// third fan-out shape alongside "everyone subscribed" and "this one owner", and
|
||||
// the catalog has no flag for it because the flag would say nothing a caller
|
||||
// does not already know by choosing the function.
|
||||
{
|
||||
id: 'team.member.joined',
|
||||
label: 'Team — new member',
|
||||
description: 'Someone joined a Team you belong to.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'team.leadership.changed',
|
||||
label: 'Team — leadership change',
|
||||
description: 'Leadership changed in a Team you belong to.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'team.forum.post',
|
||||
label: 'Team — new forum post',
|
||||
description: 'A new thread or reply in a Team forum you can read.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'team.announcement',
|
||||
label: 'Team — announcements',
|
||||
description: 'A leader posted an announcement in a Team you can read.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
|
||||
154
server/src/config/coreTriggers.js
Normal file
154
server/src/config/coreTriggers.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// ── Core's own engagement triggers ─────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.3 and Phase 2. The twin of config/coreStreams.js, and
|
||||
// deliberately the SAME FIVE IDS — that is the org lead's §7.2 decision, taken at
|
||||
// the start of this phase: **one namespace.** A trigger is not a second thing
|
||||
// standing next to a stream; it is a payload contract attached to an id that may
|
||||
// also carry a subscription toggle. `news.post` names one event, whether the
|
||||
// question being asked of it is "may I push this?" or "what may a template
|
||||
// interpolate?".
|
||||
//
|
||||
// What that buys, concretely: `notification_channel_prefs.stream_id` (§4.5) stays
|
||||
// single-keyed. Under two namespaces it would have needed a `kind` discriminator
|
||||
// in its primary key, and `news.post` would have named two different things
|
||||
// forever.
|
||||
//
|
||||
// What it costs is the rule enforced in registries.js: an id has ONE owner across
|
||||
// both facets, so a module cannot attach a payload contract to another module's
|
||||
// stream, and core cannot attach one to a module's. Core's five ids below are
|
||||
// already core's five streams, so all five are the same-owner upgrade case.
|
||||
//
|
||||
// **These declare; nothing here emits yet.** Phase 2 is the contract only — the
|
||||
// Team pipeline keeps its own hardcoded mail until Phase 6 migrates it onto the
|
||||
// engine, and this file is what it migrates ONTO. Registering the declarations a
|
||||
// phase early is the same decision registerCore() has always taken: a registry
|
||||
// whose first real exercise is a module is a registry that has already drifted.
|
||||
//
|
||||
// Every variable carries an `example`, and that is required rather than
|
||||
// decorative (§4.3 property 3). It is what lets the template editor preview and
|
||||
// test-send without a live game event, which is the reason template systems go
|
||||
// untested.
|
||||
|
||||
const TRIGGERS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News post published',
|
||||
description: 'A news / Five-on-Friday / newsletter post was published.',
|
||||
kind: 'event',
|
||||
// No subjectKey. The subject of a cooldown here is the USER, not the post —
|
||||
// "do not mail me about news more than once an hour" is the useful rule, and
|
||||
// keying it per post would make every cooldown a no-op. Compare the four
|
||||
// Team triggers below, where the Team genuinely is the subject.
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'Five on Friday — the Yew invasion',
|
||||
description: 'The post title.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'Four new champion spawns, and the fate of the Yew moongate…',
|
||||
description: 'A plain-text summary, already stripped of markup.' },
|
||||
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
|
||||
description: 'The post category, when it has one.' },
|
||||
// **`/site/news`, the LIST, and not a per-post path.** The example said
|
||||
// `/news/<slug>` when this was declared with no caller; Phase 11 gave it
|
||||
// one and the path turned out not to exist — `App.jsx` mounts `/site/news`
|
||||
// and nothing under it, which is why `announceJobs.logic.js` links the list
|
||||
// from the Discord and town-crier announcements too. An `example` is what
|
||||
// the template editor previews and test-sends with (§4.3 property 3), so an
|
||||
// example naming a 404 is a preview that looks right and a mail that is not.
|
||||
{ name: 'postUrl', type: 'url', required: true, example: '/site/news',
|
||||
description: 'Site-relative path to the post. The news list today — the site has no per-post route.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── Teams (TEAMS.md Part 6) ─────────────────────────────────────────────
|
||||
//
|
||||
// All four ceiling at `members` and not one of them higher. Who may be told
|
||||
// about a Team event is the access resolver's answer and always has been
|
||||
// (coreStreams.js says the same thing about the push catalog); the ceiling is
|
||||
// that rule written where a RULE EDITOR has to obey it too. Without it an
|
||||
// operator could point a rule at `authenticated` and mail a private Team's
|
||||
// forum excerpt to the whole site.
|
||||
{
|
||||
id: 'team.member.joined',
|
||||
label: 'Team — new member',
|
||||
description: 'Someone joined a Team.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'memberName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Display name of the member who joined.' },
|
||||
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
|
||||
description: 'Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team.leadership.changed',
|
||||
label: 'Team — leadership change',
|
||||
description: 'Leadership changed in a Team.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'leaderName', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'Display name of the new leader.' },
|
||||
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
|
||||
description: 'Site-relative path to the Team page.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team.forum.post',
|
||||
label: 'Team — new forum post',
|
||||
description: 'A new thread or reply in a Team forum.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Display name of the poster.' },
|
||||
{ name: 'threadTitle', type: 'string', required: true, example: 'Tuesday champ rotation',
|
||||
description: 'Title of the thread the post belongs to.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'Moving the Tuesday run an hour later…',
|
||||
description: 'Plain-text excerpt of the post body, already stripped of markup.' },
|
||||
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/412',
|
||||
description: 'Site-relative path to the post.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team.announcement',
|
||||
label: 'Team — announcement',
|
||||
description: 'A leader posted an announcement in a Team.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'Display name of the leader who posted.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'Siege practice moved to Sunday',
|
||||
description: 'The announcement title.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'We are moving practice to Sunday 8pm…',
|
||||
description: 'Plain-text excerpt of the announcement body.' },
|
||||
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/419',
|
||||
description: 'Site-relative path to the announcement.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { TRIGGERS }
|
||||
28
server/src/emailBlocks/index.js
Normal file
28
server/src/emailBlocks/index.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Email block registry entrypoint. Requiring this module registers every
|
||||
// `email.*` block definition exactly once, then re-exports the registry API, the
|
||||
// renderer and the registry-bound validator/sanitizer. Anything that needs to
|
||||
// validate or render a mail template's blocks should require THIS module, not
|
||||
// ./registry or ./render directly, so the definitions are guaranteed loaded.
|
||||
//
|
||||
// Same shape as `blocks/index.js`, on purpose — the two families are siblings
|
||||
// (see ./registry.js for why they are not one registry).
|
||||
|
||||
const registry = require('./registry')
|
||||
const render = require('./render')
|
||||
const interpolate = require('./interpolate')
|
||||
const variables = require('./variables')
|
||||
|
||||
// ── Block definitions (self-register on require) ───────────────────────────
|
||||
require('./types/heading')
|
||||
require('./types/text')
|
||||
require('./types/button')
|
||||
require('./types/divider')
|
||||
require('./types/image')
|
||||
require('./types/itemList')
|
||||
|
||||
module.exports = {
|
||||
...registry,
|
||||
...render,
|
||||
...interpolate,
|
||||
...variables,
|
||||
}
|
||||
79
server/src/emailBlocks/interpolate.js
Normal file
79
server/src/emailBlocks/interpolate.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// ── Template variable interpolation ────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2's security posture, as code: "variable interpolation is
|
||||
// HTML-escaped by default with no raw-HTML variable type in v1. A module supplies
|
||||
// data; it does not supply markup."
|
||||
//
|
||||
// The token grammar is deliberately the smallest thing that works: `{{ name }}`,
|
||||
// a bare declared variable name, and NOTHING else. No filters, no conditionals,
|
||||
// no loops, no dotted paths. Three reasons:
|
||||
//
|
||||
// - A template is operator-authored data rendered by the server. Every construct
|
||||
// added here is a construct an operator can get wrong and a construct someone
|
||||
// has to sandbox.
|
||||
// - §4.3 makes the trigger declaration the source of truth for what a template
|
||||
// may reference, and a save-time check names the offending variable. That check
|
||||
// can only be exact if a token is a name — `{{ user.profile.email }}` is not a
|
||||
// declared variable, it is an expression over one.
|
||||
// - Repetition is a BLOCK (`email.itemList`), not a template construct, so the
|
||||
// one place a template needs "for each" already has a typed, validated home.
|
||||
//
|
||||
// A token whose variable has no value at render time becomes the empty string and
|
||||
// is reported in `missing`. It does not become "undefined", which is the failure
|
||||
// §4.3's versioning paragraph is about — a renamed variable rendering as the word
|
||||
// undefined in a person's inbox.
|
||||
|
||||
// `{{ name }}` / `{{name}}`. Leading letter, then letters/digits/underscore —
|
||||
// the same shape §4.3's declarations use.
|
||||
const TOKEN_RE = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g
|
||||
|
||||
/** Escape text for interpolation into HTML. Same table as utils/htmlShell.js. */
|
||||
function htmlEscape(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct variable name a string references, in first-appearance order.
|
||||
* This is what the save-time check (Phase 5b) walks to find undeclared variables.
|
||||
* @param {unknown} str
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function scanTokens(str) {
|
||||
if (typeof str !== 'string') return []
|
||||
const found = []
|
||||
for (const m of str.matchAll(TOKEN_RE)) {
|
||||
if (!found.includes(m[1])) found.push(m[1])
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute declared variables into a string.
|
||||
*
|
||||
* @param {unknown} str
|
||||
* @param {Record<string, unknown>} values
|
||||
* @param {{ escape?: boolean, missing?: Set<string> }} [opts]
|
||||
* `escape` (default true) HTML-escapes each value — pass false ONLY for the
|
||||
* plain-text part, where there is no markup to escape into and `&` in a
|
||||
* person's inbox is a bug. `missing` collects names with no value.
|
||||
* @returns {string}
|
||||
*/
|
||||
function interpolate(str, values, opts = {}) {
|
||||
if (typeof str !== 'string' || str === '') return ''
|
||||
const escape = opts.escape !== false
|
||||
const missing = opts.missing || null
|
||||
return str.replace(TOKEN_RE, (_match, name) => {
|
||||
const value = values ? values[name] : undefined
|
||||
if (value === undefined || value === null) {
|
||||
if (missing) missing.add(name)
|
||||
return ''
|
||||
}
|
||||
const asString = typeof value === 'string' ? value : String(value)
|
||||
return escape ? htmlEscape(asString) : asString
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { TOKEN_RE, htmlEscape, scanTokens, interpolate }
|
||||
138
server/src/emailBlocks/registry.js
Normal file
138
server/src/emailBlocks/registry.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// ── The `email.*` block registry ───────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.4. A sibling of `blocks/registry.js`, not an extension of it,
|
||||
// settled with the org lead at the start of Phase 5a. Three reasons, in order of
|
||||
// how much they cost if ignored:
|
||||
//
|
||||
// 1. **These blocks render on the SERVER.** Page blocks do not: `blocks/` carries
|
||||
// `schema` / `sanitize` / `cacheTTL` and the actual drawing happens in React
|
||||
// (`client/src/blocks/BlockRenderer.jsx`). Mail has no React — a message body
|
||||
// is a string this process produces — so an email definition carries `toHtml`
|
||||
// and `toText`. `registerBlock` freezes a fixed field set and would silently
|
||||
// DROP both.
|
||||
// 2. **One registry would be one namespace.** `blocks/validateBlocks.js`'s only
|
||||
// server consumer is `pages.model.js`; registering `email.heading` into that
|
||||
// Map makes a CMS page containing an email block validate and save, and the
|
||||
// client renderer has nothing to draw for it.
|
||||
// 3. The two entry shapes genuinely differ: `cacheTTL` and `container` mean
|
||||
// nothing to a mail body, and a renderer means nothing to a cached page block.
|
||||
//
|
||||
// What IS shared is everything that is the same rule for both, and it is shared by
|
||||
// binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
|
||||
// (`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`).
|
||||
// §4.4's "do not build a second editor" is honoured where it is about the editor —
|
||||
// Phase 5b drives these through the existing block/prop-panel machinery.
|
||||
//
|
||||
// A registered definition looks like:
|
||||
// {
|
||||
// type: 'email.heading',
|
||||
// version: 1,
|
||||
// schema: (props) => [], // error strings ([] = valid)
|
||||
// sanitize: (props) => props, // optional, run on save AFTER validation
|
||||
// toHtml: (props, ctx) => '<tr>…', // a table ROW; see render.js for the shell
|
||||
// toText: (props, ctx) => 'text', // '' means "contributes nothing"
|
||||
// variables: (props) => [], // optional; see below
|
||||
// }
|
||||
//
|
||||
// `variables` exists because of ONE block, and the exception is the reason it has
|
||||
// to be declared rather than inferred. Every other block references a declared
|
||||
// variable the same way a person writes it — as a `{{token}}` inside an authored
|
||||
// string — so scanning the string props finds them all. `email.itemList` does not:
|
||||
// its `variable` prop holds a BARE NAME (`items`), because the block iterates the
|
||||
// value rather than interpolating it. A save-time check that only scanned tokens
|
||||
// would pass a template pointing its one repeating block at a variable no trigger
|
||||
// declares, and the failure would surface as an empty digest in someone's inbox.
|
||||
// A block that reads a variable by any means other than a token says so here.
|
||||
//
|
||||
// `ctx` is the render context (render.js): resolved brand values, an `interp`
|
||||
// that substitutes declared variables HTML-escaped, and `interpText` that does
|
||||
// the same without escaping for the plain-text part.
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
// Same envelope as a page block — deliberately the same constant list, because
|
||||
// the shared validator enforces it and the two must not diverge.
|
||||
const { RESERVED_KEYS } = require('../blocks/registry')
|
||||
|
||||
/**
|
||||
* Register an email block definition. Throws on a missing type, a duplicate, or a
|
||||
* missing renderer — all three are programmer errors surfaced at boot.
|
||||
* @param {object} def
|
||||
* @returns {object} the normalized, frozen definition
|
||||
*/
|
||||
function registerEmailBlock(def) {
|
||||
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
|
||||
throw new Error('registerEmailBlock: a block definition needs a string `type`')
|
||||
}
|
||||
if (!def.type.startsWith('email.')) {
|
||||
// The prefix is not needed to disambiguate — this is its own Map — but a
|
||||
// stored blocks array should say what it is when someone reads the row.
|
||||
throw new Error(`registerEmailBlock: ${def.type} must be namespaced "email."`)
|
||||
}
|
||||
if (registry.has(def.type)) {
|
||||
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
|
||||
}
|
||||
if (typeof def.toHtml !== 'function' || typeof def.toText !== 'function') {
|
||||
// §4.4: "Every block type gets a toText(props) alongside its renderer, so a
|
||||
// text part always exists." A block that can only produce HTML would make a
|
||||
// published template's text part depend on which blocks it happened to use.
|
||||
throw new Error(`registerEmailBlock: ${def.type} needs both toHtml and toText`)
|
||||
}
|
||||
if (def.schema != null && typeof def.schema !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.schema must be a function`)
|
||||
}
|
||||
if (def.sanitize != null && typeof def.sanitize !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.sanitize must be a function`)
|
||||
}
|
||||
if (def.variables != null && typeof def.variables !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.variables must be a function`)
|
||||
}
|
||||
const entry = Object.freeze({
|
||||
type: def.type,
|
||||
label: def.label || def.type,
|
||||
version: Number.isInteger(def.version) ? def.version : 1,
|
||||
schema: def.schema || null,
|
||||
sanitize: def.sanitize || null,
|
||||
toHtml: def.toHtml,
|
||||
toText: def.toText,
|
||||
// Null, not a default `() => []`: `variables.js` distinguishes "this block
|
||||
// declares no non-token references" from "this block was never asked", and
|
||||
// only the second is worth a comment when a new block type is added.
|
||||
variables: def.variables || null,
|
||||
// The shared walk reads these; email has no containers, and saying so here is
|
||||
// what lets `makeValidateBlocks` be the same function for both families.
|
||||
container: false,
|
||||
containerSlots: Object.freeze([]),
|
||||
})
|
||||
registry.set(entry.type, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||
function getEmailBlock(type) {
|
||||
return registry.get(type) || null
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `type` is a registered email block. */
|
||||
function hasEmailBlock(type) {
|
||||
return registry.has(type)
|
||||
}
|
||||
|
||||
/** @returns {object[]} all registered definitions (registration order). */
|
||||
function listEmailBlocks() {
|
||||
return [...registry.values()]
|
||||
}
|
||||
|
||||
/** Drop every registered block. Test-only. */
|
||||
function _resetRegistry() {
|
||||
registry.clear()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESERVED_KEYS,
|
||||
registerEmailBlock,
|
||||
getEmailBlock,
|
||||
hasEmailBlock,
|
||||
listEmailBlocks,
|
||||
_resetRegistry,
|
||||
}
|
||||
196
server/src/emailBlocks/render.js
Normal file
196
server/src/emailBlocks/render.js
Normal file
@@ -0,0 +1,196 @@
|
||||
// ── Rendering a block array into a mail body ───────────────────────────────
|
||||
//
|
||||
// Pure and synchronous: everything that needs a database — the brand values, the
|
||||
// resolved theme, the site title — is resolved by `engagement/templates.js` and
|
||||
// arrives here as a plain object. That split is what lets the whole renderer be
|
||||
// tested without a MariaDB, and it is why the byte-comparison test for the five
|
||||
// transactional bodies (§5a acceptance) is a unit test rather than a live send.
|
||||
//
|
||||
// **The shell contributes structure and NO content.** No appended footer, no
|
||||
// injected logo, no "sent by" line. Two reasons, and the second is the load-bearing
|
||||
// one:
|
||||
//
|
||||
// - A person's mail must say what the operator wrote and nothing else. An
|
||||
// unsubscribe line is a variable inside the template (§4.6.1 lists
|
||||
// `unsubscribeUrl` for exactly the two templates that need one), so an operator
|
||||
// can move it, reword it, or see that a transactional mail correctly has none.
|
||||
// - **The HTML and text parts must say the same things.** A shell that put a
|
||||
// footer only in the HTML would make every message's two parts disagree, which
|
||||
// is a deliverability signal and, worse, means the text reader is told less
|
||||
// than the HTML reader. Every block produces both halves; nothing else does.
|
||||
//
|
||||
// The HTML is table-based and inline-styled throughout, which is not a stylistic
|
||||
// choice: `<div>` layout and a `<style>` block are the two things mail clients
|
||||
// most reliably break.
|
||||
|
||||
const { htmlEscape, interpolate } = require('./interpolate')
|
||||
const { getEmailBlock } = require('./registry')
|
||||
const { makeValidateBlocks } = require('../blocks/validateBlocks')
|
||||
const { makeSanitizeBlocks } = require('../blocks/sanitizeBlocks')
|
||||
const { isSafeUrl } = require('../blocks/propHelpers')
|
||||
|
||||
// Bound to the email registry — the same walk the page family gets, so the
|
||||
// envelope rules, id uniqueness and schema dispatch cannot drift between them.
|
||||
const validateEmailBlocks = makeValidateBlocks(getEmailBlock, { maxBlocks: 60 })
|
||||
const sanitizeEmailBlocks = makeSanitizeBlocks(getEmailBlock)
|
||||
|
||||
// A stack every mail client resolves. No webfont: a @font-face in mail is either
|
||||
// stripped or silently ignored, and the fallback is what the reader sees anyway.
|
||||
const FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
|
||||
|
||||
/**
|
||||
* The mail palette — a light scaffold plus the deployment's accent.
|
||||
*
|
||||
* **Only the accent comes from the theme, and that is deliberate.** Every shipped
|
||||
* preset (`config/themePresets.js`) is a DARK palette, and mail is not a page: a
|
||||
* dark-background body is what §4.6.2 names as rendering "unreadable dark-on-dark
|
||||
* in about a third of inboxes", because a good share of clients invert or force a
|
||||
* background of their own. Deriving a light palette from a dark one would be a
|
||||
* guess at six colours; taking the one colour that carries the brand — the accent,
|
||||
* used for the button and for links — is exact. §4.6.1's property 2 holds either
|
||||
* way: no seeded template contains a hex code, so one prebuilt image running as
|
||||
* any shard mails in that shard's colour.
|
||||
*
|
||||
* @param {{ accent?: string }} [theme] resolved theme tokens
|
||||
*/
|
||||
function palette(theme = {}) {
|
||||
const accent = isHex(theme.accent) ? theme.accent : '#7f99bd'
|
||||
return Object.freeze({
|
||||
accent,
|
||||
onAccent: readableOn(accent),
|
||||
heading: '#151a20',
|
||||
text: '#33404d',
|
||||
muted: '#6b7885',
|
||||
rule: '#dfe4ea',
|
||||
page: '#f4f6f8',
|
||||
card: '#ffffff',
|
||||
fontStack: FONT_STACK,
|
||||
})
|
||||
}
|
||||
|
||||
function isHex(v) {
|
||||
return typeof v === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(v)
|
||||
}
|
||||
|
||||
/** Black or white text over `hex`, whichever a reader can actually read. */
|
||||
function readableOn(hex) {
|
||||
let h = hex.slice(1)
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||
// Relative luminance (WCAG). 0.45 rather than 0.5: the accents here are mid-tone
|
||||
// and white-on-mid reads better than black-on-mid at button weight.
|
||||
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
||||
const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
return L > 0.45 ? '#151a20' : '#ffffff'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the render context every block's `toHtml` / `toText` receives.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Record<string, unknown>} opts.values variable values
|
||||
* @param {object} [opts.theme] resolved theme tokens
|
||||
* @param {string} [opts.baseUrl] absolute site base, for relative urls
|
||||
* @param {Set<string>} [opts.missing] collects unresolved variable names
|
||||
*/
|
||||
function buildContext({ values = {}, theme = {}, baseUrl = '', missing = new Set() }) {
|
||||
const base = String(baseUrl || '').replace(/\/+$/, '')
|
||||
const ctx = {
|
||||
values,
|
||||
missing,
|
||||
palette: palette(theme),
|
||||
escape: htmlEscape,
|
||||
/** Interpolate + HTML-escape — for anything going into markup. */
|
||||
h: (s) => interpolate(s, values, { escape: true, missing }),
|
||||
/** Interpolate WITHOUT escaping — for the plain-text part only. */
|
||||
t: (s) => interpolate(s, values, { escape: false, missing }),
|
||||
/**
|
||||
* Interpolate a URL and re-check it. Returns the URL or null.
|
||||
*
|
||||
* A stored `{{resetUrl}}` says nothing about where it points; the value
|
||||
* arrives from a caller or a module at render time. Checking only the stored
|
||||
* literal would mean a variable carrying `javascript:` becomes an href.
|
||||
*/
|
||||
safeHref: (s) => {
|
||||
const url = interpolate(s, values, { escape: false, missing })
|
||||
return url && isSafeUrl(url) ? url : null
|
||||
},
|
||||
/** Same-origin path → absolute URL; http(s) unchanged; anything else null. */
|
||||
absolute: (url) => {
|
||||
if (!url) return null
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
if (url.startsWith('/')) return base ? `${base}${url}` : null
|
||||
return null
|
||||
},
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a blocks array into the two body parts.
|
||||
*
|
||||
* Blocks are joined by a blank line in text and stacked as table rows in HTML.
|
||||
* A block whose `toText` returns '' contributes nothing to the text part and does
|
||||
* not leave a doubled blank line behind it (`email.divider` is the case).
|
||||
*
|
||||
* @returns {{ html: string, text: string }} html is the ROWS, not a document
|
||||
*/
|
||||
function renderBlocks(blocks, ctx) {
|
||||
const rows = []
|
||||
const paras = []
|
||||
for (const block of Array.isArray(blocks) ? blocks : []) {
|
||||
if (block && block.visible === false) continue
|
||||
const def = block && typeof block.type === 'string' ? getEmailBlock(block.type) : null
|
||||
if (!def) continue // unreachable after validation; never emit an unknown block
|
||||
const props = block.props && typeof block.props === 'object' ? block.props : {}
|
||||
try {
|
||||
const html = def.toHtml(props, ctx)
|
||||
if (html) rows.push(html)
|
||||
const text = def.toText(props, ctx)
|
||||
if (text) paras.push(text)
|
||||
} catch {
|
||||
// One misbehaving block must not cost the whole message. Skipped in both
|
||||
// parts together, so the two never disagree about what the mail contains.
|
||||
}
|
||||
}
|
||||
return { html: rows.join(''), text: paras.join('\n\n') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap rendered rows in the mail document.
|
||||
* @param {string} rowsHtml
|
||||
* @param {object} ctx
|
||||
* @param {string} [title] the <title>, shown by a few webmail clients
|
||||
*/
|
||||
function renderDocument(rowsHtml, ctx, title = '') {
|
||||
const p = ctx.palette
|
||||
return (
|
||||
'<!doctype html><html><head><meta charset="utf-8" />' +
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1" />' +
|
||||
// Tells a client that inverts colours that this body already handles both,
|
||||
// so it leaves the palette alone instead of inverting the card to near-black.
|
||||
'<meta name="color-scheme" content="light" />' +
|
||||
'<meta name="supported-color-schemes" content="light" />' +
|
||||
`<title>${htmlEscape(title)}</title></head>` +
|
||||
`<body style="margin:0;padding:0;background:${p.page};">` +
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${p.page};">` +
|
||||
'<tr><td align="center" style="padding:24px 12px;">' +
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" ` +
|
||||
`style="width:100%;max-width:600px;background:${p.card};border:1px solid ${p.rule};border-radius:6px;">` +
|
||||
'<tr><td style="padding:28px 28px 16px 28px;">' +
|
||||
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">' +
|
||||
rowsHtml +
|
||||
'</table></td></tr></table></td></tr></table></body></html>'
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FONT_STACK,
|
||||
palette,
|
||||
readableOn,
|
||||
buildContext,
|
||||
renderBlocks,
|
||||
renderDocument,
|
||||
validateEmailBlocks,
|
||||
sanitizeEmailBlocks,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user