diff --git a/.env.example b/.env.example index 22abc57..4eccd07 100644 --- a/.env.example +++ b/.env.example @@ -57,7 +57,7 @@ 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 Gmail refresh token, the uo-link auth +# 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 @@ -98,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 diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index add43e5..06251e5 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -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: diff --git a/README.md b/README.md index 097b90b..306d44d 100644 --- a/README.md +++ b/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, …) | @@ -584,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` / `/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) | @@ -677,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. --- diff --git a/client/src/App.jsx b/client/src/App.jsx index 88ff3f7..48aa783 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -42,6 +42,12 @@ 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' @@ -54,10 +60,12 @@ 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' @@ -183,7 +191,34 @@ export default function App() { actions that publish a game-written name is applied per request on the server, from the caller's live role (TEAMS.md 2.9). */} } /> + {/* 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. */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* 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. */} + } /> + } /> {/* Installed modules' admin pages, at /admin//…, already inside RequireAuth + AdminLayout. A module cannot supply its own auth wrapper — only an optional { roles }, which core applies as the @@ -205,6 +240,9 @@ export default function App() { } /> } /> } /> + {/* Opened from a mailbox, so public like the reset page above — the + token is the proof, and confirming issues no session. */} + } /> } /> {/* PUBLIC, and grouped with the other tokened landings above rather than with the portal below: the person following an unsubscribe @@ -224,7 +262,14 @@ export default function App() { } /> } /> } /> - } /> + {/* 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. */} + } /> + } /> {/* Installed modules' player-portal pages, at /player//…. This group's own routes are absolute (its layout route has no path), so the prefix is written here rather than inherited — the one diff --git a/client/src/api/client.js b/client/src/api/client.js index 1da6829..d732f14 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -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'), @@ -200,6 +230,25 @@ export const api = { // 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 } }), @@ -294,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) => @@ -321,6 +376,83 @@ 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`. @@ -435,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 }), @@ -455,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'), diff --git a/client/src/components/NotificationBell.jsx b/client/src/components/NotificationBell.jsx new file mode 100644 index 0000000..efec630 --- /dev/null +++ b/client/src/components/NotificationBell.jsx @@ -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 ( + + ) +} + +// "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 ( +
+ + + {open && ( +
+
+ + Notifications + + {unread > 0 && ( + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {!error && items.length === 0 && ( +

+ Nothing here yet. +

+ )} + + {items.map((item) => ( + + ))} + + 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 → + +
+ )} +
+ ) +} diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 84706f0..21eee19 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -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() { ), )} + {/* 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 && } {!loading && ( +

+ Email address +

+

+ {account.email ? ( + <> + Currently {account.email} + {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.' + )} +

+ + {pending && ( +
+ {pending} is waiting to be confirmed. It is not in + use until you open the link in that email. +
+ + +
+
+ )} + +
+ + {hasPassword && ( + + )} +
+ +
+ {(msg || error) && ( +

+ {error || msg} +

+ )} +
+ + ) +} diff --git a/client/src/emailBlocks/index.js b/client/src/emailBlocks/index.js new file mode 100644 index 0000000..adb9920 --- /dev/null +++ b/client/src/emailBlocks/index.js @@ -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' diff --git a/client/src/emailBlocks/registry.js b/client/src/emailBlocks/registry.js new file mode 100644 index 0000000..027fa92 --- /dev/null +++ b/client/src/emailBlocks/registry.js @@ -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(), + } +} diff --git a/client/src/emailBlocks/types.jsx b/client/src/emailBlocks/types.jsx new file mode 100644 index 0000000..fde6eb2 --- /dev/null +++ b/client/src/emailBlocks/types.jsx @@ -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 ( +
+ {variables.map((v) => ( + + ))} +
+ ) +} + +/** 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 ( +
+ + onChange(`${value || ''}${token}`)} /> +
+ ) +} + +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 }) => ( +
+ onChange({ ...props, level })} + options={[ + ['h1', 'Large'], + ['h2', 'Medium'], + ['h3', 'Small'], + ]} + /> + onChange({ ...props, text })} + /> +
+ ), +}) + +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 }) => ( +
+ onChange({ ...props, text })} + /> + + + +
+ ), +}) + +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 }) => ( +
+ onChange({ ...props, label })} + /> + onChange({ ...props, url })} + /> + onChange({ ...props, textLead })} + /> +
+ ), +}) + +registerEmailBlock({ + type: 'email.divider', + version: 1, + label: 'Divider', + icon: '—', + hint: 'A horizontal rule.', + defaults: () => ({}), + editor: () => ( +

+ A divider has nothing to configure. +

+ ), +}) + +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 }) => ( +
+ onChange({ ...props, url })} + /> + onChange({ ...props, alt })} + /> + + { + 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) + }} + /> + +
+ ), +}) + +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 ( +
+ {lists.length ? ( + onChange({ ...props, variable })} + options={[['', 'Choose a list…'], ...lists.map((v) => [v.name, v.name])]} + /> + ) : ( + +

+ 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. +

+
+ )} + onChange({ ...props, emptyText })} + /> +
+ ) + }, +}) diff --git a/client/src/lib/engagementRules.js b/client/src/lib/engagementRules.js new file mode 100644 index 0000000..7e1eb59 --- /dev/null +++ b/client/src/lib/engagementRules.js @@ -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` +} diff --git a/client/src/lib/notificationPaths.js b/client/src/lib/notificationPaths.js new file mode 100644 index 0000000..f571447 --- /dev/null +++ b/client/src/lib/notificationPaths.js @@ -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' diff --git a/client/src/modules/TeamNotifyToggle.jsx b/client/src/modules/TeamNotifyToggle.jsx index e48c8e6..b473b66 100644 --- a/client/src/modules/TeamNotifyToggle.jsx +++ b/client/src/modules/TeamNotifyToggle.jsx @@ -96,7 +96,7 @@ export default function TeamNotifyToggle({ externalId, moduleId }) { {/* 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. */} - All notification settings + All notification settings ) } diff --git a/client/src/modules/version.js b/client/src/modules/version.js index c0c833a..bd6f97b 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,26 @@ // 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.9.0 - a module may ship its own message bodies and rules: +// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase +// 11b, decision 7). Nothing on this half changed - a seed is server-side data +// and core's seeders write it on the boot path - but the bodies it ships are +// edited through the template editor this half already renders, and an operator +// meets them there. This file bumps for the reason at the top: the two halves +// state ONE version, and a module declares one `coreApi` range against both. +// 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on +// this half changed: a ceiling is declared on the server's `api` and enforced +// there, and the admin screens that render one read the vocabulary from +// `GET /admin/engagement/triggers` rather than holding a copy. This file bumps +// anyway, for the reason at the top - the two halves state ONE version. +// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2). Nothing +// on this half changed: every member the version adds is on the server's `api` +// and `ctx` (registerEventTriggers, registerAudiences, ctx.events.emit, +// ctx.inbox.push). This file bumps anyway, for the reason at the top — the two +// halves state ONE version, and a module declares one `coreApi` range against +// both. The web surfaces the engagement system needs (the rules and template +// editors, the in-app inbox) land in Phases 4, 5 and 7 and will add to this half +// then. // 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half // changed yet: the two client additions the version covers are the `team.overview` // and `team.member.row` slots, and a slot can only be declared by the page that @@ -45,4 +65,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.6.0' +export const MODULE_API_VERSION = '1.9.0' diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 55b5f2c..e8ec10c 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -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 = () => const IconPulse = () => const IconUser = () => +const IconBell = () => const IconNav = () => const IconPalette = () => const IconModules = () => +const IconMail = () => +const IconList = () => +const IconTemplate = () => +const IconSpark = () => +const IconLog = () => // 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` @@ -89,6 +96,25 @@ export const NAV = [ { 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'] }, + ], + }, { title: 'System', items: [ @@ -109,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 }, ], }, @@ -157,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 @@ -176,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' } @@ -417,6 +457,11 @@ export default function AdminLayout() { {title}
+ {/* 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. */} + View site → diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx index acdd108..4c4ad6e 100644 --- a/client/src/routes/admin/views/AccountAdmin.jsx +++ b/client/src/routes/admin/views/AccountAdmin.jsx @@ -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 && } + ) diff --git a/client/src/routes/admin/views/Dashboard.jsx b/client/src/routes/admin/views/Dashboard.jsx index 2ea25da..9a2ca63 100644 --- a/client/src/routes/admin/views/Dashboard.jsx +++ b/client/src/routes/admin/views/Dashboard.jsx @@ -66,6 +66,34 @@ export default function Dashboard() { return (
+ {/* 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) => ( +
+ {w.message} + {w.href && ( + <> + {' '} + Open settings + + )} +
+ ))}
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 ( + + ) + } + + return ( + + ) +} + 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

{error}

if (!config) return null - const connected = config.hasRefreshToken + const catalog = config.transports || [] + const selected = catalog.find((t) => t.id === transport) return (

Email delivery

- Sends the contact form through Gmail over OAuth2, delivered to the - Contact email 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 + Contact email above. Credentials are stored encrypted + and never shown again.

- {banner && ( + {config.hadLegacyConnection && !config.hasCredential && (
- {banner.text} + This deployment was connected with the old Gmail sign-in, which has been + removed. No mail is being sent. Enter SMTP credentials + below to restore it — for Gmail, use smtp.gmail.com port 587 + with an app password.
)} - {!config.googleConfigured && ( -

- The Google authentication provider needs a client ID and secret before - you can connect a Gmail account. -

+ {catalog.length > 1 && ( + )} - {!connected ? ( -
- + + {config.hasCredential && ( + -
- ) : ( - <> - - - - -
- - - - -
- - )} + )} +
{msg && {msg}} diff --git a/client/src/routes/admin/views/EngagementAudiences.jsx b/client/src/routes/admin/views/EngagementAudiences.jsx new file mode 100644 index 0000000..de09168 --- /dev/null +++ b/client/src/routes/admin/views/EngagementAudiences.jsx @@ -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 ( +
+ + {(declared?.params || []).map((p) => ( + + ))} + {canNegate && ( + + )} + +
+ ) +} + +// ── 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 ( +
+
+ {isNew ? 'New saved audience' : `Editing “${segment.name}”`} +
+ +
+ + +
+ +
+ {group.nodes.length === 0 && ( +

+ No audiences yet. A saved audience is built out of the lists installed modules declare. +

+ )} + {group.nodes.map((node, i) => { + const negated = node.op === 'not' + const leaf = negated ? node.nodes[0] : node + return ( + toggleNegate(i)} + onChange={(next) => replaceAt(i, negated ? { op: 'not', nodes: [next] } : next)} + onRemove={() => setNodes(group.nodes.filter((_, j) => j !== i))} + /> + ) + })} + + {!audiences.length && ( + + No module currently declares any. Install one, or use a plain audience on the rule itself. + + )} +
+ + {canNegate ? ( +

+ “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. +

+ ) : ( +

+ “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. +

+ )} + + {(errors.length > 0 || localError) && ( +
    + {(errors.length ? errors : [localError]).map((e) =>
  • {e}
  • )} +
+ )} + +
+ + +
+
+ ) +} + +// ── 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 + if (!segments) return + + if (editing) { + return ( +
+ { setEditing(null); await load() }} + onCancel={() => setEditing(null)} + /> +
+ ) + } + + return ( +
+
+

+ 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. +

+ +
+ + {rowError && ( +

{rowError}

+ )} + +
+ + + + + + + + + + + {segments.length === 0 && ( + + + + )} + {segments.map((s) => ( + + + + + + + + ))} + +
NameMade ofReaches at mostRight now +
+ No saved audiences yet. +
+ {s.name} + {s.dormant && ( +
+ + Dormant + +
+ )} +
+ {describeExpression(s.expression, audiencesById)} + {s.ceiling} + {reach[s.id] ? ( + describeReach(reach[s.id]) + ) : ( + + )} + + + +
+
+ +
+
What modules currently declare
+ {audiences.length === 0 ? ( +

+ Nothing. Audiences come from installed modules — core declares none, because core knows no + game vocabulary. +

+ ) : ( +
    + {audiences.map((a) => ( +
  • + {a.label}{a.id}, reaches at + most “{a.ceiling}” + {(a.params || []).length ? ` (${a.params.map((p) => p.id).join(', ')})` : ''} +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/client/src/routes/admin/views/EngagementRules.jsx b/client/src/routes/admin/views/EngagementRules.jsx new file mode 100644 index 0000000..0995149 --- /dev/null +++ b/client/src/routes/admin/views/EngagementRules.jsx @@ -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 ( + + Dormant + + ) +} + +// ── 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 ( +
+
+ {isNew ? 'New rule' : `Editing “${rule.name}”`} +
+ +
+ + +
+ + {trigger?.description && ( +

+ {trigger.description} +

+ )} + + {/* ── Audience ── */} +
Who it reaches
+
+ + + +
+ {preview && ( +

+ {describeReach(preview)} +

+ )} + {/* 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) && ( +

+ {audienceWarning(form)} +

+ )} + {trigger && audienceChoices.length <= 1 && ( +

+ This event only permits “{trigger.ceiling}”. The audience a rule may use is capped by the + event itself, not by the rule. +

+ )} + + {/* ── Channels ── */} +
How it is delivered
+
+ {catalog.channels.map((c) => ( +
+ + {form.channels.includes(c.id) && ( + set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })} + /> + )} +
+ ))} +
+

+ Every channel is opt-in: a rule reaches only the people who turned that channel on for this + event in their own notification settings. +

+ + {/* ── Conditions ── */} +
Only when…
+ {!conditionState.editable ? ( +
+

+ 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. +

+
+            {JSON.stringify(form.conditions, null, 2)}
+          
+ +
+ ) : ( + <> + {conditionState.rows.length > 1 && ( + + )} + {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 ( +
+ + + {takesValue && ( + patch({ value: e.target.value })} + /> + )} + +
+ ) + })} + + {!variables.length && ( + + Choose a trigger first — its declared variables are what a condition can talk about. + + )} + + )} + + {/* ── Timing and the ceiling ── */} +
Timing
+
+ + + +
+

+ 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. +

+ + {form.delaySeconds > 0 && ( + + )} + + {errors.length > 0 && ( +
    + {errors.map((e) =>
  • {e}
  • )} +
+ )} + +
+ + + {isNew && ( + + A new rule is created switched off. Turn it on from the list when you are happy with it. + + )} +
+
+ ) +} + +// ── 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 + if (!catalog || !rules) return + + if (editing) { + return ( +
+ { setEditing(null); await load() }} + onCancel={() => setEditing(null)} + /> +
+ ) + } + + return ( +
+ {teamRulesAllOff(rules) && ( +
+ Team notification emails are off. 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. +
+ )} + + {newsRulesAllOff(rules) && ( +
+ News notifications are off. 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. +
+ )} + +
+

+ 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. +

+ +
+ + {rowError && ( +

{rowError}

+ )} + +
+ + + + + + + + + + + {rules.length === 0 && ( + + + + )} + {rules.map((rule) => ( + + + + + + + + ))} + +
RuleTriggerWhat it doesState +
+ No rules yet. Nothing is being sent. +
{rule.name}{rule.trigger_id} + {describeRule(rule, { segmentsById })} + + + {rule.dormant && ( +
+ )} +
+ + +
+
+ + {rules.some((r) => r.dormant) && ( +

+ 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. +

+ )} +
+ ) +} diff --git a/client/src/routes/admin/views/EngagementSendLog.jsx b/client/src/routes/admin/views/EngagementSendLog.jsx new file mode 100644 index 0000000..bba6231 --- /dev/null +++ b/client/src/routes/admin/views/EngagementSendLog.jsx @@ -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 + if (error) return + + const to = Math.min(offset + PAGE, total) + + return ( +
+
+

+ 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. +

+ +
+ + {total === 0 ? ( +

+ {status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'} +

+ ) : ( + <> +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
WhenWhatToChannelResultDetail
+ {new Date(r.created_at).toLocaleString()} + + {/* 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 + ? Test send from the template editor + : {r.trigger_id}} + + {r.user_id ? user #{r.user_id} : } + + {r.channel} + {r.transport && · {r.transport}} + + {STATUS_LABEL[r.status] || r.status} + + {r.detail || ''} +
+
+ +
+ + {offset + 1}–{to} of {total} + +
+ + +
+
+ + )} +
+ ) +} diff --git a/client/src/routes/admin/views/EngagementSuppressions.jsx b/client/src/routes/admin/views/EngagementSuppressions.jsx new file mode 100644 index 0000000..ac9fc44 --- /dev/null +++ b/client/src/routes/admin/views/EngagementSuppressions.jsx @@ -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 + if (error) return + + const to = Math.min(offset + PAGE, total) + const summary = Object.entries(byReason).filter(([, n]) => n > 0) + + return ( +
+

+ 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. +

+ + {summary.length > 0 && ( +
+ {summary.map(([r, n]) => ( +
+
{n}
+
+ {REASON_LABEL[r] || r} +
+
+ ))} +
+ )} + +
+ + +
+ + +
+ +
+ + {note && ( +

{note}

+ )} + + {total === 0 ? ( +

+ {reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'} +

+ ) : ( + <> +
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
AddressReasonDetailChannelSince
+ {r.address_masked + ? {r.address_masked} + : not recorded} + + {REASON_LABEL[r.reason] || r.reason} + + {r.detail || ''} + {r.channel} + {new Date(r.created_at).toLocaleString()} +
+
+ +
+ + {offset + 1}–{to} of {total} + +
+ + +
+
+ + )} +
+ ) +} diff --git a/client/src/routes/admin/views/EngagementTemplates.jsx b/client/src/routes/admin/views/EngagementTemplates.jsx new file mode 100644 index 0000000..ecead56 --- /dev/null +++ b/client/src/routes/admin/views/EngagementTemplates.jsx @@ -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 ( +
+