21 Commits

Author SHA1 Message Date
e7f5f24809 Regenerate Swagger with the CMS pages endpoints
swagger-output.json now documents GET/POST /admin/pages, GET/PATCH/DELETE
/admin/pages/:id, POST /admin/pages/:id/{unprotect,preview}, and the public
GET /public/pages/:slug + /public/pages/:id/preview/:token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:57:39 -05:00
1dd7603f54 Add page builder admin UI + public page route (step 5 + step 6 client)
- PagesAdmin: list view of pages (title/slug/status/protected/updated) with
  new/edit navigation and a View link to the live page.
- PageBuilder: full-page block canvas — palette (adds any registered block),
  per-block editor cards with show/hide, up/down + native drag reorder, and
  remove; Content / Settings tabs; SEO metadata + layout/nav settings panels;
  publish/unpublish; protect (PATCH) and password-gated unprotect (modal);
  draft preview (mints a token, opens /preview/:id/:token); delete (blocked
  while protected). Surfaces server block-validation details on save.
- CmsPage: public renderer for /:slug (published; staff see drafts) and the
  token-gated /preview/:id/:token, rendering blocks via BlockList and
  reflecting the page title/meta.
- Routing: /:slug catch-all after all named routes + /preview/:id/:token
  outside the maintenance gate; admin /admin/pages, /pages/new, /pages/:id.
- api client: public page/pagePreview + admin pages CRUD/unprotect/preview.
- AdminLayout: "Pages" nav entry (Content group) with icon.
- theme.css: builder canvas + preview-banner + shell-wide styles.

Client builds clean (216 modules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:54:36 -05:00
4d87c5f627 Add pages API: model, controller, routes, preview (steps 4/6/7/8)
Backend for the CMS page builder, all under the existing /api/v1:

- pages.model: authoritative save gate — validates blocks against the
  registry and sanitizes them on every create/update; maps rows to/from the
  grouped API shape (metadata / settings); slug validated + reserved-checked
  at create and immutable after; `protected` can be set true via PATCH but
  only cleared via the unprotect path; published_at stamped on first publish.
- sanitizeBlocks: post-validation normalizer (applies each block's sanitize,
  stamps version, defaults visible, recurses container slots).
- reservedSlugs: guards page slugs from shadowing named routes/API namespaces.
- Admin routes (staff-gated): GET/POST /pages, GET/PATCH/DELETE /pages/:id,
  POST /pages/:id/unprotect (password step-up, verified against the caller's
  own hash, never logged), POST /pages/:id/preview (1h token). Audit-logs
  create/publish/unpublish/protect/unprotect/delete.
- Public routes: GET /public/pages/:slug (published; staff see drafts; site-
  mode gated) and GET /public/pages/:id/preview/:token (ungated, token is the
  access control). Preview token primitives added to auth/token.js.
- Swagger annotations for all new endpoints.

Verified end-to-end: model integration test against the dev DB (sanitize,
invalid-block rejection, slug immutability, protected/unprotect, dup/reserved
slug, published_at) + authenticated HTTP smoke (201 create, 400 invalid
blocks, publish, public slug fetch, preview mint+fetch, 403 delete-protected,
401 wrong-password unprotect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:48:31 -05:00
764fb0c069 Add Wave 1 block renderers + editors (page builder step 3, client half)
Client block registry now carries a renderer, edit form, palette label/icon,
and defaults for all seven Wave 1 blocks (self-registering via
client/src/blocks/types/*): heading, rich_text, image, two_column, cta,
divider, quote.

- BlockRenderer + BlockList render stored blocks via the registry (respect
  `visible`, tolerate unknown types), reading getBlock from ./registry to
  avoid the index -> twoColumn -> BlockRenderer cycle.
- editorKit: shared Field/TextField/TextAreaField/SelectField styled with the
  existing admin form classes; rich_text editor reuses RichTextEditor
  (variant post), image editor reuses the shared uploader.
- two_column editor is a mini per-column canvas (add from the leaf-only
  palette, edit via each block's registry editor, reorder, remove).
- theme.css: public block styles (heading/image alignment/cta/quote/
  two-column responsive grid) + column sub-block editor styles.

Verified: all 11 modules transform cleanly under esbuild. Full visual
verification comes with the builder UI (step 5) + public route (step 6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:37:34 -05:00
6d31869ba2 Add Wave 1 block server schemas (page builder step 3, server half)
Register all seven Wave 1 block types with their server-side validation
schemas, self-registering via server/src/blocks/types/*:
heading, rich_text, image, two_column (container), cta, divider, quote.

- propHelpers.js: shared validators (isSafeUrl rejects javascript:/data:/
  protocol-relative, enum/required/optional text, strict key allowlist).
- rich_text carries a `sanitize` normalizer (registry now supports it) that
  runs html through the shared cleanBody allowlist on save.
- Registry entrypoint requires the type modules so all schemas load.

Verified: all 7 register; valid blocks pass; malformed props yield precise
per-path errors; one-level nesting cap enforced; rich_text sanitize strips
script/onerror.

Client renderers + editors (step 3 client half) still to come.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:30:58 -05:00
fcef08e9b6 Add pages table + block registry scaffold (page builder step 2)
New `pages` table: slug/title/blocks(JSON-as-text)/status/protected, author
FK, grouped SEO metadata + layout/nav settings columns (added up front per
spec — cheap now, painful to retrofit), published_at mirroring posts.

Block registry scaffold, server and client, defining the pattern without
any block types yet (Wave 1 lands in step 3):
- server/src/blocks: registry (register/get/list, reserved envelope keys,
  container metadata) + validateBlocks (authoritative save-time gate:
  envelope, registered-type, per-block schema, one-level nesting cap) +
  index entrypoint that will register Wave 1 defs.
- client/src/blocks: mirror registry carrying renderer/editor/palette +
  makeBlockId, plus index entrypoint.

Verified: schema applies idempotently against the dev DB (pages table +
indexes present); validator exercised for empty/non-array/unknown-type/
bad-envelope/duplicate-id/nested-container cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:26:14 -05:00
6180e8a071 Add rich-text alignment controls (left/center/right)
Shared RichTextEditor gains @tiptap/extension-text-align for heading and
paragraph nodes, serializing alignment as inline text-align on the block
node so it round-trips through save/reload. Fixed once at the shared
component so it also flows into the upcoming rich_text and two_column
page blocks.

Server sanitize allowlist now permits `style` on p/h1-h6, constrained by
allowedStyles to text-align (left/right/center/justify) only; all other
CSS properties and values are stripped.

Step 1 of the CMS Page Builder spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:21:17 -05:00
d7fc2dccb7 Merge pull request 'Modernize email: Gmail OAuth2 sending + admin sidebar redesign' (#46) from feature/email-oauth2 into main
Reviewed-on: UOM/website#46
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-08 03:33:42 +00:00
455e850b91 Merge branch 'main' into feature/email-oauth2 2026-07-08 03:33:11 +00:00
f8652c2399 Modernize email: Gmail OAuth2 sending, configured under Settings
Retire env-var SMTP basic-auth and send the contact form through Gmail over
OAuth2 (SMTP XOAUTH2), configured in Admin -> Settings -> Email via an in-app
"Connect Gmail" consent flow. Reuses the existing google SSO OAuth client; the
captured refresh token is stored AES-GCM-encrypted (write-only over the API,
never returned), mirroring the auth-provider and Discord-bot secret patterns.

- schema: new email_config singleton table (mirrors bot_config)
- model: emailConfig.{db,model} with encrypted refresh token + getSafe/getWithSecret
- mailer: nodemailer OAuth2 transport (client id/secret from the google provider
  row), contact recipient = contact_email setting, mailto: fallback preserved,
  plus sendTest()
- routes/controller: /admin/email config, connect start+callback (ssoState CSRF
  + PKCE), test, disconnect
- client: EmailDelivery section on the Settings page + api methods; Settings copy
  now spells out that contact_email is the delivery recipient
- docs/env: drop SMTP_*/CONTACT_TO from env examples; update README/BACKEND_DESIGN
- tests: emailConfig.model + mailer suites (8 new; full suite 142 pass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeCQEJZr1AFJN4Bgcmvh3
2026-07-07 22:29:27 -05:00
5b3ab7f282 Merge pull request 'Redesign admin/staff sidebar: collapsible categories, icons, role-accurate nav' (#45) from feature/admin-nav-redesign into main
Reviewed-on: UOM/website#45
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-08 02:56:15 +00:00
17d42cebfe Redesign admin sidebar: collapsible categories, icons, role-accurate nav
Regroup the flat 12-link staff sidebar into collapsible category sections
(Content / Moderation / System, with Dashboard and Account ungrouped) and
add a small inline-SVG icon per item. Category collapse state persists in
localStorage and the group holding the active route auto-opens.

Gate each item by role to match server-side enforcement so the sidebar no
longer shows links that would 403: Content is admin/editor, Moderation is
admin/moderator, System (Users, Settings, Hero Editor, Authentication,
Discord Bot, Web Bot Activity) is admin-only. Existing moderator confinement
(Moderation + Account only, plus redirect) is preserved.

Rename "Bot Activity" to "Web Bot Activity" to distinguish the bot-scoring
view from the Discord Bot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:54:37 -05:00
5da27879e5 Merge pull request 'Gate /admin to staff roles; role-aware login redirects for players' (#44) from feature/player-accounts into main
Reviewed-on: UOM/website#44
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-07 01:02:53 +00:00
cda0c16149 Merge branch 'main' into feature/player-accounts 2026-07-07 01:02:39 +00:00
82807d18d9 Gate /admin to staff roles; role-aware login redirects for players
Introducing the 'player' role turned 'logged-in' into 'logged-in but possibly
untrusted', but the admin router only gated content routes (dashboard, posts,
wiki, uploads) by isLoggedIn — so a player session could reach editor-tier
endpoints. Fixes:
- Backend: requireRole('admin','editor','moderator') at the admin router base;
  players now 403 on all /admin/* and use /player instead.
- Client: RequireAuth redirects a signed-in player to /account (mirrors
  RequirePlayer).
- Both login pages redirect by role after auth (player -> /account, staff ->
  /admin) so you land in the right shell whichever door you used.

Verified live: player token 403s on /admin/dashboard + /admin/users, 200s on
/player/account; browser click-through confirms a player at /admin and at
/admin/login both land on /account. 134 server tests green; client builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 19:57:31 -05:00
d72deff2cc Merge pull request 'Player accounts: self-service player role, registration, and portal' (#43) from feature/player-accounts into main
Reviewed-on: UOM/website#43
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-06 20:29:42 +00:00
387db52510 Fix player getAccount has_password: read the raw row, not sanitized req.user
req.user comes from getById which strips password_hash, so has_password was
always false — the account page mis-rendered a real password account as the
SSO-only 'set a password' variant (and the change-password form omitted the
required current-password field). Read the raw row for that one flag.

Caught by a browser click-through of the /account portal. Adds a regression
test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 05:11:36 -05:00
5daf260db9 Player accounts frontend + Swagger + schema comment fix
- Player portal: RequirePlayer guard, /account routes (login, register,
  settings) with shared PlayerShell; register reads /public/settings derived
  flags; AuthContext.register; api.register + api.player.* namespace.
- Admin UI: player role + status/email + reset-password hint in UserEditor,
  status column + badge-player in UsersAdmin, player_registration select in
  SettingsAdmin; 'disabled' SSO error copy.
- Swagger: Player tag + RegisterRequest/ChangeUsername/ChangePassword/
  PlayerAccount/OkFlag schemas; regenerated swagger-output.json.
- Fix: remove a semicolon from a schema.sql inline comment that broke the
  statement splitter in ensureSchema.

Verified against the live dev DB: schema migrations apply (player enum,
nullable password_hash, email/status/last_login_ip, seeded setting); 21-check
controller smoke (register gating, dup/reserved, null-hash rules, self change
username/password with session re-issue surviving the cutoff, SSO-only initial
password, banned-login refusal); case-insensitive uniqueness; public settings
expose only derived registration flags. Client builds; 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:49:12 -05:00
f8bcc7f6a3 Player accounts backend: schema, registration, self-service, SSO provision
- Widen users.role enum to include 'player'; make password_hash nullable;
  add email/email_verified/status/last_login_ip; pin username _ci collation.
- POST /auth/register (honeypot + registerLimiter + botScore, reserved-name
  blocklist, duplicate->409, auto-login). player_registration setting gates it.
- SSO auto-provision in finishLogin (setting-gated); return/portal-aware SSO
  redirects for the player portal; status refusal on login + requireAuth.
- New /player self-service group (account, change username/password, TOTP,
  identities), reusing account.controller; accountChangeLimiter.
- Admin: 'player' role + status/email on user create/update, role/status audit,
  player_registration enum validation, derived public registration flags.
- usernamePolicy module (reserved, sanitize, derive, dedup) + unit tests;
  extend SSO callback tests. 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:36:51 -05:00
cdd916e199 Ignore local .plans/ planning docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:17:16 -05:00
4ab46410be Merge pull request 'Moderation dashboard: staff dashboard, user history, notes, event capture (Phase 6a + 6b)' (#42) from feature/moderation-dashboard into main
Reviewed-on: UOM/website#42
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 20:51:38 +00:00
85 changed files with 8082 additions and 180 deletions

View File

@@ -53,13 +53,10 @@ TOTP_CHALLENGE_TTL=5m
ADMIN_USERNAME= ADMIN_USERNAME=
ADMIN_PASSWORD= ADMIN_PASSWORD=
# Email (optional). If SMTP_HOST is blank, the contact endpoint tells the # Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
# client to fall back to a mailto: link instead. # env. It reuses the Google auth provider's OAuth client and stores an encrypted
SMTP_HOST= # refresh token in the DB. Until it's connected, the contact form falls back to
SMTP_PORT=587 # a mailto: link (recipient = the `contact_email` site setting).
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
# CORS — only needed for local dev when the Vite dev server is a different origin. # CORS — only needed for local dev when the Vite dev server is a different origin.
CLIENT_ORIGIN=http://localhost:5173 CLIENT_ORIGIN=http://localhost:5173

3
.gitignore vendored
View File

@@ -31,5 +31,8 @@ Thumbs.db
.vscode/ .vscode/
.idea/ .idea/
# local planning docs (not part of the tracked codebase)
.plans/
# scratch / temp scripts # scratch / temp scripts
_*.ps1 _*.ps1

View File

@@ -234,10 +234,13 @@ who"; `activity_log` provides the history feed.
## 7. Email ## 7. Email
`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to `utils/mailer.js` (nodemailer) sends through **Gmail over OAuth2 (SMTP XOAUTH2)**, configured in
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only. Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the flow (`/admin/email/*`) that captures a refresh token, stored AES-GCM-encrypted in the `email_config`
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds. singleton (never returned over the API). The OAuth client id/secret are reused from the `google`
auth-providers row. Recipient is the `contact_email` site setting. If email is unconfigured/disabled,
`POST /public/contact` returns `{fallback:"mailto", email}` so the client renders a `mailto:` link
instead. Errors never leak credentials.
--- ---
@@ -287,11 +290,7 @@ COOKIE_SECURE=true
COOKIE_NAME=uomm_token COOKIE_NAME=uomm_token
ADMIN_USERNAME= ADMIN_USERNAME=
ADMIN_PASSWORD= ADMIN_PASSWORD=
SMTP_HOST= # Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
CLIENT_ORIGIN=http://localhost:5173 CLIENT_ORIGIN=http://localhost:5173
``` ```

View File

@@ -39,7 +39,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
| 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) | | 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) | | Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 | | Frontend | React 18, Vite 5, React Router 6 |
| Email | Nodemailer (SMTP) with a `mailto:` fallback | | Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` | | API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
| Deploy | Docker Compose, Pangolin reverse proxy | | Deploy | Docker Compose, Pangolin reverse proxy |
@@ -276,8 +276,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA | | `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA |
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step | | `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) | | `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` | | _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only | | `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity | | `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) | | `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
@@ -343,8 +342,9 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs - `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
behind Pangolin (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials), behind Pangolin (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
`.env` git-ignored. Passwords and request bodies are never logged. SMTP is optional — the contact `.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
form falls back to a `mailto:` link when unconfigured. OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
contact form falls back to a `mailto:` link when unconfigured.
--- ---

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tiptap/extension-image": "^2.27.2", "@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2", "@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/react": "^2.27.2", "@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2", "@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2", "diff": "^5.2.2",
@@ -1483,6 +1484,19 @@
"@tiptap/core": "^2.7.0" "@tiptap/core": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-text-align": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz",
"integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": { "node_modules/@tiptap/extension-text-style": {
"version": "2.27.2", "version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@tiptap/extension-image": "^2.27.2", "@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2", "@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/react": "^2.27.2", "@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2", "@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2", "diff": "^5.2.2",

View File

@@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
import { SiteProvider } from './contexts/SiteContext.jsx' import { SiteProvider } from './contexts/SiteContext.jsx'
import MaintenanceGate from './components/MaintenanceGate.jsx' import MaintenanceGate from './components/MaintenanceGate.jsx'
import RequireAuth from './components/RequireAuth.jsx' import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx' import RoleGate from './components/RoleGate.jsx'
// Public // Public
@@ -17,12 +18,15 @@ import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx' import Status from './routes/public/Status.jsx'
import Wiki from './routes/wiki/Wiki.jsx' import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
// Admin // Admin
import AdminLogin from './routes/admin/AdminLogin.jsx' import AdminLogin from './routes/admin/AdminLogin.jsx'
import AdminLayout from './routes/admin/AdminLayout.jsx' import AdminLayout from './routes/admin/AdminLayout.jsx'
import Dashboard from './routes/admin/views/Dashboard.jsx' import Dashboard from './routes/admin/views/Dashboard.jsx'
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx' import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx' import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
import HeroEditor from './routes/admin/views/HeroEditor.jsx' import HeroEditor from './routes/admin/views/HeroEditor.jsx'
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
@@ -35,6 +39,11 @@ import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx' import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
export default function App() { export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
@@ -59,8 +68,15 @@ export default function App() {
<Route path="/site/status" element={<Status />} /> <Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} /> <Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} /> <Route path="/wiki/:slug" element={<WikiArticle />} />
{/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} />
</Route> </Route>
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
preview link works regardless of site mode. */}
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
{/* Admin */} {/* Admin */}
<Route path="/admin/login" element={<AdminLogin />} /> <Route path="/admin/login" element={<AdminLogin />} />
<Route <Route
@@ -73,6 +89,9 @@ export default function App() {
> >
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="posts" element={<PostsAdmin />} /> <Route path="posts" element={<PostsAdmin />} />
<Route path="pages" element={<PagesAdmin />} />
<Route path="pages/new" element={<PageBuilder />} />
<Route path="pages/:id" element={<PageBuilder />} />
<Route path="wiki" element={<WikiAdmin />} /> <Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} /> <Route path="hero" element={<HeroEditor />} />
<Route path="settings" element={<SettingsAdmin />} /> <Route path="settings" element={<SettingsAdmin />} />
@@ -96,6 +115,18 @@ export default function App() {
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />
</Route> </Route>
{/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} />
<Route
path="/account"
element={
<RequirePlayer>
<PlayerAccount />
</RequirePlayer>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</SiteProvider> </SiteProvider>

View File

@@ -44,6 +44,10 @@ export const api = {
// `extra` carries the honeypot field (and any future login fields). // `extra` carries the honeypot field (and any future login fields).
login: (username, password, extra = {}) => login: (username, password, extra = {}) =>
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }), req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
// Public self-registration (player accounts). `extra` carries the honeypot +
// optional email. Returns { user } and sets the session cookie on success.
register: (username, password, extra = {}) =>
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) => loginTotp: (challenge, code) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }), req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by // Second factor for an SSO login (challenge is held in an httpOnly cookie set by
@@ -69,6 +73,10 @@ export const api = {
wikiCategories: () => req('/public/wiki/categories'), wikiCategories: () => req('/public/wiki/categories'),
wikiTags: () => req('/public/wiki/tags'), wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`), wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
// is fetched by id + token.
page: (slug) => req(`/public/pages/${slug}`),
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }), contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
// ----- admin ----- // ----- admin -----
@@ -93,6 +101,15 @@ export const api = {
fd.append('image', file) fd.append('image', file)
return req('/admin/uploads', { method: 'POST', body: fd, raw: true }) return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
}, },
// ----- CMS pages (block-based page builder) -----
listPages: () => req('/admin/pages'),
getPage: (id) => req(`/admin/pages/${id}`),
createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
unprotectPage: (id, password) =>
req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
listWiki: (params = '') => req(`/admin/wiki${params}`), listWiki: (params = '') => req(`/admin/wiki${params}`),
getWiki: (slug) => req(`/admin/wiki/${slug}`), getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }), createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
@@ -185,6 +202,29 @@ export const api = {
// ----- Discord bot control (admin only) ----- // ----- Discord bot control (admin only) -----
getDiscordBotConfig: () => req('/admin/discord-bot/config'), getDiscordBotConfig: () => req('/admin/discord-bot/config'),
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }), saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
// ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
emailConnectUrl: () => req('/admin/email/connect/start'),
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
},
// ----- player self-service (role: 'player') -----
// Mirrors the admin account methods but self-scoped under /player. The change
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
player: {
getAccount: () => req('/player/account'),
changeUsername: (username) =>
req('/player/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
linkedIdentities: () => req('/player/account/identities'),
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
}, },
} }

View File

@@ -0,0 +1,26 @@
// Renders stored blocks via their registry component. Used by the public page
// route, the draft preview, and (recursively) the two_column block. Kept
// separate from the registry so both the renderer and the builder can import it.
// Import the lookup from the registry directly (not ./index) to avoid a cycle:
// index → types/twoColumn → BlockRenderer. The page route/builder import ./index,
// which registers every block before anything renders.
import { getBlock } from './registry.js'
/**
* Render one block. A block with `visible === false` renders nothing (admins
* hide blocks without deleting them). An unknown type also renders nothing —
* server validation prevents storing one, so this only guards a client/server
* registry skew rather than crashing the whole page.
*/
export default function BlockRenderer({ block }) {
if (!block || block.visible === false) return null
const def = getBlock(block.type)
if (!def || !def.component) return null
const Component = def.component
return <Component props={block.props || {}} block={block} />
}
/** Render an ordered array of blocks (array position = display order). */
export function BlockList({ blocks }) {
return (blocks || []).map((block) => <BlockRenderer key={block.id} block={block} />)
}

View File

@@ -0,0 +1,64 @@
// Shared form controls for block editors, styled with the existing admin design
// system (.field-label / .input / .select). Every block's editor is a
// ({ props, onChange }) component; these keep the seven of them consistent and
// short. onChange always receives the full next props object.
export function Field({ label, hint, children }) {
return (
<label style={{ display: 'block' }}>
<span className="field-label">{label}</span>
{children}
{hint && (
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
{hint}
</span>
)}
</label>
)
}
export function TextField({ label, hint, value, onChange, placeholder, maxLength }) {
return (
<Field label={label} hint={hint}>
<input
type="text"
className="input"
value={value ?? ''}
placeholder={placeholder}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
)
}
export function TextAreaField({ label, hint, value, onChange, placeholder, rows = 4, maxLength }) {
return (
<Field label={label} hint={hint}>
<textarea
className="input"
rows={rows}
value={value ?? ''}
placeholder={placeholder}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
style={{ resize: 'vertical', fontFamily: 'inherit' }}
/>
</Field>
)
}
// options: array of [value, label] tuples.
export function SelectField({ label, hint, value, onChange, options }) {
return (
<Field label={label} hint={hint}>
<select className="select" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
{options.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</Field>
)
}

View File

@@ -0,0 +1,19 @@
// Client block registry entrypoint. Importing this module registers every
// browser-side block definition (renderer + editor + palette entry) exactly
// once, then re-exports the registry API. The page builder and the public page
// renderer should import from HERE, not ./registry, so the definitions are
// loaded before anything reads the registry.
//
// Wave 1 definitions are registered below as each block is built (spec build
// order step 3), one import per block.
export * from './registry'
// ── Wave 1 block definitions (self-register on import) ─────────────────
import './types/heading.jsx'
import './types/richText.jsx'
import './types/image.jsx'
import './types/twoColumn.jsx'
import './types/cta.jsx'
import './types/divider.jsx'
import './types/quote.jsx'

View File

@@ -0,0 +1,84 @@
// Block registry (client side) — mirrors the server registry
// (server/src/blocks/registry.js) but carries the browser-only concerns: the
// React renderer, the admin edit form, and the palette icon/label. The page
// builder's palette, drag-reorder canvas, per-block edit panel, and the public
// page renderer all read from this registry, so adding a block later is one
// entry here (plus its server-side schema entry) rather than edits scattered
// across the builder and renderer.
//
// A registered definition looks like:
// {
// type: 'heading', // must match the server registry type
// version: 1, // must match the server schema version
// label: 'Heading', // palette display name
// icon: 'heading', // palette icon key
// component: HeadingBlock, // renderer: (props) => JSX
// editor: HeadingEditor, // admin edit form: ({ props, onChange }) => JSX
// defaults: () => ({ ... }), // starting props when a block is added
// container: false, // true only for two_column
// containerSlots: [], // ['left','right'] for two_column
// }
//
// This module only defines the pattern; Wave 1 definitions register via
// ./index.js as each block is built (spec build order step 3).
const registry = new Map()
// Kept in sync with the server's RESERVED_KEYS — the only top-level keys on a
// stored block object. Exported so the builder can construct envelopes without
// hard-coding the shape.
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
/**
* Register a block definition. Throws on a duplicate type — a programmer error
* caught at module load, not runtime.
* @param {object} def
* @returns {object} the stored definition
*/
export function registerBlock(def) {
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
throw new Error('registerBlock: a block definition needs a string `type`')
}
if (registry.has(def.type)) {
throw new Error(`registerBlock: 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,
component: def.component || null,
editor: def.editor || null,
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
container: Boolean(def.container),
containerSlots: def.containerSlots ? [...def.containerSlots] : [],
}
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
export function getBlock(type) {
return registry.get(type) || null
}
/** @returns {boolean} whether `type` is a registered block. */
export function hasBlock(type) {
return registry.has(type)
}
/** @returns {object[]} all registered definitions (registration order). */
export function listBlocks() {
return [...registry.values()]
}
/**
* Generate a stable block id. Called once when a block is added to the canvas;
* never derived from array position, so a reorder keeps ids intact (they are the
* React key and the future revision-history join point).
* @returns {string}
*/
export function makeBlockId() {
const rand = Math.random().toString(36).slice(2, 8).toUpperCase()
return `b_${rand}`
}

View File

@@ -0,0 +1,63 @@
// cta block — a call-to-action button/link. Renders as an anchor styled with the
// existing button system (primary / secondary).
import { registerBlock } from '../registry'
import { SelectField, TextField } from '../editorKit.jsx'
const STYLES = [
['primary', 'Primary'],
['secondary', 'Secondary'],
]
function CtaBlock({ props }) {
if (!props.url || !props.text) return null
const style = props.style === 'secondary' ? 'secondary' : 'primary'
// External links get a safe rel; same-origin relative links don't need it.
const external = /^https?:\/\//i.test(props.url)
return (
<div className="page-cta-wrap">
<a
className={`btn btn-sq page-cta page-cta--${style}`}
href={props.url}
{...(external ? { rel: 'noopener noreferrer nofollow' } : {})}
>
{props.text}
</a>
</div>
)
}
function CtaEditor({ props, onChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<TextField
label="Button text"
value={props.text}
maxLength={100}
onChange={(text) => onChange({ ...props, text })}
/>
<TextField
label="URL"
hint="A full https:// link or a same-site path like /wiki/getting-started."
value={props.url}
placeholder="https://…"
onChange={(url) => onChange({ ...props, url })}
/>
<SelectField
label="Style"
value={props.style || 'primary'}
onChange={(style) => onChange({ ...props, style })}
options={STYLES}
/>
</div>
)
}
registerBlock({
type: 'cta',
version: 1,
label: 'Button',
icon: '⇥',
component: CtaBlock,
editor: CtaEditor,
defaults: () => ({ text: '', url: '', style: 'primary' }),
})

View File

@@ -0,0 +1,25 @@
// divider block — a pure spacer / horizontal rule. No props, so its editor is
// just a note.
import { registerBlock } from '../registry'
function DividerBlock() {
return <hr className="page-divider" />
}
function DividerEditor() {
return (
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
A divider has no options it adds a horizontal rule and spacing.
</p>
)
}
registerBlock({
type: 'divider',
version: 1,
label: 'Divider',
icon: '—',
component: DividerBlock,
editor: DividerEditor,
defaults: () => ({}),
})

View File

@@ -0,0 +1,46 @@
// heading block — plain-text section heading (h1h4). Text is rendered as text
// (React escapes it); use rich_text for inline markup.
import { registerBlock } from '../registry'
import { SelectField, TextField } from '../editorKit.jsx'
const LEVELS = [
['h1', 'Heading 1'],
['h2', 'Heading 2'],
['h3', 'Heading 3'],
['h4', 'Heading 4'],
]
const VALID = ['h1', 'h2', 'h3', 'h4']
function HeadingBlock({ props }) {
const Tag = VALID.includes(props.level) ? props.level : 'h2'
return <Tag className="page-heading">{props.text}</Tag>
}
function HeadingEditor({ props, onChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<SelectField
label="Level"
value={props.level || 'h2'}
onChange={(level) => onChange({ ...props, level })}
options={LEVELS}
/>
<TextField
label="Text"
value={props.text}
maxLength={200}
onChange={(text) => onChange({ ...props, text })}
/>
</div>
)
}
registerBlock({
type: 'heading',
version: 1,
label: 'Heading',
icon: 'H',
component: HeadingBlock,
editor: HeadingEditor,
defaults: () => ({ level: 'h2', text: '' }),
})

View File

@@ -0,0 +1,100 @@
// image block — a single image with optional caption and alignment. Upload
// reuses the shared admin uploader (returns { url }); the block stays URL-based
// until the Wave 3 asset picker lands.
import { useState } from 'react'
import { registerBlock } from '../registry'
import { api } from '../../api/client.js'
import { SelectField, TextField } from '../editorKit.jsx'
const ALIGN = [
['left', 'Left'],
['center', 'Center'],
['right', 'Right'],
['full', 'Full width'],
]
const VALID = ['left', 'center', 'right', 'full']
function ImageBlock({ props }) {
if (!props.src) return null
const align = VALID.includes(props.alignment) ? props.alignment : 'center'
return (
<figure className={`page-image page-image--${align}`}>
<img src={props.src} alt={props.alt || ''} />
{props.caption && <figcaption>{props.caption}</figcaption>}
</figure>
)
}
function ImageEditor({ props, onChange }) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
async function onUpload(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
setUploading(true)
setError('')
try {
const { url } = await api.admin.upload(file)
onChange({ ...props, src: url })
} catch (err) {
setError(err.message || 'Upload failed')
} finally {
setUploading(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<span className="field-label">Image</span>
<input
type="file"
accept="image/*"
onChange={onUpload}
className="sans"
style={{ color: 'var(--muted)', fontSize: '0.85rem', display: 'block' }}
/>
{uploading && <span className="sans dim" style={{ fontSize: '0.8rem' }}> uploading</span>}
{error && <span className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{error}</span>}
{props.src && (
<img
src={props.src}
alt=""
style={{ display: 'block', marginTop: 10, maxWidth: '100%', borderRadius: 8, border: '1px solid var(--line)' }}
/>
)}
</div>
<TextField
label="Alt text"
hint="Describes the image for screen readers and when it fails to load."
value={props.alt}
maxLength={300}
onChange={(alt) => onChange({ ...props, alt })}
/>
<TextField
label="Caption (optional)"
value={props.caption}
maxLength={500}
onChange={(caption) => onChange({ ...props, caption })}
/>
<SelectField
label="Alignment"
value={props.alignment || 'center'}
onChange={(alignment) => onChange({ ...props, alignment })}
options={ALIGN}
/>
</div>
)
}
registerBlock({
type: 'image',
version: 1,
label: 'Image',
icon: '🖼',
component: ImageBlock,
editor: ImageEditor,
defaults: () => ({ src: '', alt: '', caption: '', alignment: 'center' }),
})

View File

@@ -0,0 +1,43 @@
// quote block — a pull quote with optional attribution.
import { registerBlock } from '../registry'
import { TextAreaField, TextField } from '../editorKit.jsx'
function QuoteBlock({ props }) {
if (!props.text) return null
return (
<figure className="page-quote">
<blockquote>{props.text}</blockquote>
{props.attribution && <figcaption> {props.attribution}</figcaption>}
</figure>
)
}
function QuoteEditor({ props, onChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<TextAreaField
label="Quote"
value={props.text}
rows={3}
maxLength={1000}
onChange={(text) => onChange({ ...props, text })}
/>
<TextField
label="Attribution (optional)"
value={props.attribution}
maxLength={200}
onChange={(attribution) => onChange({ ...props, attribution })}
/>
</div>
)
}
registerBlock({
type: 'quote',
version: 1,
label: 'Quote',
icon: '❝',
component: QuoteBlock,
editor: QuoteEditor,
defaults: () => ({ text: '', attribution: '' }),
})

View File

@@ -0,0 +1,39 @@
// rich_text block — HTML from the shared rich-text editor. Rendered inside the
// same `.prose` styling as wiki/news bodies, sanitized on render as defense in
// depth (the server also sanitizes on save).
import { lazy, Suspense } from 'react'
import DOMPurify from 'dompurify'
import { registerBlock } from '../registry'
const RichTextEditor = lazy(() => import('../../components/RichTextEditor.jsx'))
function RichTextBlock({ props }) {
return (
<div
className="prose page-rich-text"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(props.html || '') }}
/>
)
}
function RichTextEditorForm({ props, onChange }) {
return (
<Suspense fallback={<span className="spin" />}>
<RichTextEditor
value={props.html || ''}
onChange={(html) => onChange({ ...props, html })}
variant="post"
/>
</Suspense>
)
}
registerBlock({
type: 'rich_text',
version: 1,
label: 'Rich text',
icon: '¶',
component: RichTextBlock,
editor: RichTextEditorForm,
defaults: () => ({ html: '' }),
})

View File

@@ -0,0 +1,120 @@
// two_column block — the only container. Holds two ordered arrays of sub-blocks
// (`left`, `right`). Sub-blocks are leaf blocks only (no nested containers — the
// one-level cap the server also enforces), so the column editor's palette is the
// set of non-container registered blocks.
import { registerBlock, getBlock, listBlocks, makeBlockId } from '../registry'
import BlockRenderer from '../BlockRenderer.jsx'
// ── Renderer ──────────────────────────────────────────────────────────
function TwoColumnBlock({ props }) {
const left = Array.isArray(props.left) ? props.left : []
const right = Array.isArray(props.right) ? props.right : []
return (
<div className="page-two-column">
<div className="page-column">
{left.map((b) => (
<BlockRenderer key={b.id} block={b} />
))}
</div>
<div className="page-column">
{right.map((b) => (
<BlockRenderer key={b.id} block={b} />
))}
</div>
</div>
)
}
// ── Column editor ─────────────────────────────────────────────────────
// Manages one side's array: add (from the leaf palette), edit each via its own
// registry editor, reorder, remove.
function ColumnEditor({ title, items, onChange }) {
const list = Array.isArray(items) ? items : []
const palette = listBlocks().filter((b) => !b.container)
function addBlock(type) {
const def = getBlock(type)
if (!def) return
const block = { id: makeBlockId(), type, version: def.version, visible: true, props: def.defaults() }
onChange([...list, block])
}
function updateAt(i, nextProps) {
onChange(list.map((b, j) => (j === i ? { ...b, props: nextProps } : b)))
}
function removeAt(i) {
onChange(list.filter((_, j) => j !== i))
}
function move(i, dir) {
const j = i + dir
if (j < 0 || j >= list.length) return
const next = [...list]
;[next[i], next[j]] = [next[j], next[i]]
onChange(next)
}
return (
<div className="pb-column-editor">
<div className="pb-column-head">
<span className="field-label" style={{ margin: 0 }}>{title}</span>
<select
className="select pb-add-select"
value=""
onChange={(e) => {
if (e.target.value) addBlock(e.target.value)
e.target.value = ''
}}
>
<option value="">+ Add block</option>
{palette.map((b) => (
<option key={b.type} value={b.type}>
{b.label}
</option>
))}
</select>
</div>
{list.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>Empty column.</p>
)}
{list.map((block, i) => {
const def = getBlock(block.type)
const Editor = def?.editor
return (
<div key={block.id} className="pb-subblock">
<div className="pb-subblock-head">
<span className="sans dim" style={{ fontSize: '0.78rem' }}>{def?.label || block.type}</span>
<div className="pb-subblock-actions">
<button type="button" className="pill pb-mini" disabled={i === 0} onClick={() => move(i, -1)} title="Move up"></button>
<button type="button" className="pill pb-mini" disabled={i === list.length - 1} onClick={() => move(i, 1)} title="Move down"></button>
<button type="button" className="pill pb-mini" onClick={() => removeAt(i)} title="Remove"></button>
</div>
</div>
{Editor && <Editor props={block.props || {}} onChange={(p) => updateAt(i, p)} />}
</div>
)
})}
</div>
)
}
function TwoColumnEditor({ props, onChange }) {
return (
<div className="pb-two-column-editor">
<ColumnEditor title="Left column" items={props.left} onChange={(left) => onChange({ ...props, left })} />
<ColumnEditor title="Right column" items={props.right} onChange={(right) => onChange({ ...props, right })} />
</div>
)
}
registerBlock({
type: 'two_column',
version: 1,
label: 'Two columns',
icon: '▥',
component: TwoColumnBlock,
editor: TwoColumnEditor,
defaults: () => ({ left: [], right: [] }),
container: true,
containerSlots: ['left', 'right'],
})

View File

@@ -1,7 +1,9 @@
import { Navigate, useLocation } from 'react-router-dom' import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext.jsx' import { useAuth } from '../contexts/AuthContext.jsx'
// Gate for /admin/* — redirects to the login screen when not authenticated. // Gate for /admin/* — redirects to the login screen when not authenticated, and
// bounces a signed-in player to their own portal (the admin API 403s them anyway;
// this keeps the UI honest and mirrors RequirePlayer).
export default function RequireAuth({ children }) { export default function RequireAuth({ children }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const location = useLocation() const location = useLocation()
@@ -16,5 +18,8 @@ export default function RequireAuth({ children }) {
if (!user) { if (!user) {
return <Navigate to="/admin/login" state={{ from: location }} replace /> return <Navigate to="/admin/login" state={{ from: location }} replace />
} }
if (user.role === 'player') {
return <Navigate to="/account" replace />
}
return children return children
} }

View File

@@ -0,0 +1,23 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext.jsx'
// Gate for the /account player portal. Redirects to the player login when there
// is no session, or when the signed-in user is not a player (staff manage their
// own account under /admin/account). Server-side requireRole('player') is the
// real enforcement; this just keeps the UI honest.
export default function RequirePlayer({ children }) {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) {
return (
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg-deep)' }}>
<span className="spin" />
</div>
)
}
if (!user || user.role !== 'player') {
return <Navigate to="/account/login" state={{ from: location }} replace />
}
return children
}

View File

@@ -3,6 +3,7 @@ import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image' import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import { api } from '../api/client.js' import { api } from '../api/client.js'
// Toolbar button. // Toolbar button.
@@ -25,6 +26,22 @@ function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]) return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
} }
// Alignment glyph: three lines justified to the given side.
function AlignIcon({ align }) {
const rows = {
left: [[2, 14], [2, 10], [2, 12]],
center: [[2, 14], [4, 12], [3, 13]],
right: [[2, 14], [6, 14], [4, 14]],
}[align]
return (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
{rows.map(([x1, x2], i) => (
<line key={i} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} />
))}
</svg>
)
}
// Toolbar variants: // Toolbar variants:
// 'full' — every control, incl. the internal wiki-page link picker (wiki use). // 'full' — every control, incl. the internal wiki-page link picker (wiki use).
// 'post' — full minus the wiki-page picker (no page-list context in posts). // 'post' — full minus the wiki-page picker (no page-list context in posts).
@@ -42,6 +59,11 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
StarterKit.configure({ heading: { levels: [2, 3] } }), StarterKit.configure({ heading: { levels: [2, 3] } }),
Link.configure({ openOnClick: false, autolink: true }), Link.configure({ openOnClick: false, autolink: true }),
Image.configure({ inline: false }), Image.configure({ inline: false }),
// Alignment stored as `text-align` on the block node (heading/paragraph),
// so it round-trips through save/reload as inline style. Shared here means
// every consumer — post editor, and the future rich_text / two_column
// blocks — gets it for free.
TextAlign.configure({ types: ['heading', 'paragraph'] }),
], ],
content: value || '', content: value || '',
onUpdate: ({ editor }) => onChange(editor.getHTML()), onUpdate: ({ editor }) => onChange(editor.getHTML()),
@@ -131,6 +153,16 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
</Btn> </Btn>
<span className="rte-sep" /> <span className="rte-sep" />
<Btn title="Align left" active={editor.isActive({ textAlign: 'left' })} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
<AlignIcon align="left" />
</Btn>
<Btn title="Align center" active={editor.isActive({ textAlign: 'center' })} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
<AlignIcon align="center" />
</Btn>
<Btn title="Align right" active={editor.isActive({ textAlign: 'right' })} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
<AlignIcon align="right" />
</Btn>
<span className="rte-sep" />
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}> <Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
🔗 🔗
</Btn> </Btn>

View File

@@ -30,6 +30,14 @@ export function AuthProvider({ children }) {
return data return data
}, []) }, [])
// Public self-registration (player). Creates the account, sets the session
// cookie, and returns { user }. `extra` carries the honeypot + optional email.
const register = useCallback(async (username, password, extra) => {
const data = await api.register(username, password, extra)
if (data.user) setUser(data.user)
return data
}, [])
// Step 2 for TOTP users: exchange the challenge + code for a real session. // Step 2 for TOTP users: exchange the challenge + code for a real session.
const loginTotp = useCallback(async (challenge, code) => { const loginTotp = useCallback(async (challenge, code) => {
const data = await api.loginTotp(challenge, code) const data = await api.loginTotp(challenge, code)
@@ -54,7 +62,7 @@ export function AuthProvider({ children }) {
}, []) }, [])
return ( return (
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}> <AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

View File

@@ -1,36 +1,99 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
// `roles` (when present) restricts which roles see a nav item. Items without it // Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
// are shown to admin/editor as before. Moderators are further confined to just // One shared frame keeps them terse; each item just supplies its path(s).
// their own section + account security (see the redirect effect below). function Icon({ children, size = 16 }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
)
}
const IconHome = () => <Icon><path d="M3 10.5 12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></Icon>
const IconPosts = () => <Icon><path d="M5 3h14v18H5z" /><path d="M8 8h8M8 12h8M8 16h5" /></Icon>
const IconWiki = () => <Icon><path d="M4 4h9a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4z" /><path d="M20 4h-2a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h2z" /></Icon>
const IconPages = () => <Icon><path d="M5 3h9l5 5v13H5z" /><path d="M14 3v5h5" /><path d="M8 13h8M8 17h8" /></Icon>
const IconActivity = () => <Icon><path d="M3 12h4l3 8 4-16 3 8h4" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconUsers = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20a6 6 0 0 1 12 0" /><path d="M16 6a3 3 0 0 1 0 6M17 20a6 6 0 0 0-3-5" /></Icon>
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconHero = () => <Icon><path d="M3 5h18v14H3z" /><circle cx="8" cy="10" r="1.6" /><path d="M4 18l5-5 3 3 3-4 5 6" /></Icon>
const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18 12v3M15 12v2" /></Icon>
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
// (when present) matches server-side enforcement so the sidebar never shows a
// link that would 403; an item without `roles` is visible to everyone.
// Moderators are further confined to just their section + account (see below).
const NAV = [ const NAV = [
{ to: '/admin', label: 'Dashboard', end: true }, {
{ to: '/admin/posts', label: 'Posts' }, items: [
{ to: '/admin/wiki', label: 'Wiki' }, { to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
{ to: '/admin/hero', label: 'Hero Editor' }, ],
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] }, },
{ to: '/admin/settings', label: 'Settings' }, {
{ to: '/admin/activity', label: 'Activity' }, title: 'Content',
{ to: '/admin/bot-activity', label: 'Bot Activity' }, items: [
{ to: '/admin/discord-bot', label: 'Discord Bot' }, { to: '/admin/posts', label: 'Posts', icon: IconPosts, roles: ['admin', 'editor'] },
{ to: '/admin/auth-providers', label: 'Authentication' }, { to: '/admin/pages', label: 'Pages', icon: IconPages, roles: ['admin', 'editor'] },
{ to: '/admin/users', label: 'Users' }, { to: '/admin/wiki', label: 'Wiki', icon: IconWiki, roles: ['admin', 'editor'] },
{ to: '/admin/account', label: 'Account' }, { to: '/admin/activity', label: 'Activity', icon: IconActivity, roles: ['admin', 'editor'] },
],
},
{
title: 'Moderation',
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
],
},
{
title: 'System',
items: [
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
],
},
{
items: [
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
] ]
const COLLAPSE_KEY = 'admin.nav.collapsed'
const TITLES = { const TITLES = {
'/admin': 'Dashboard', '/admin': 'Dashboard',
'/admin/posts': 'Posts', '/admin/posts': 'Posts',
'/admin/pages': 'Pages',
'/admin/wiki': 'Wiki Pages', '/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor', '/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation', '/admin/moderation': 'Moderation',
'/admin/settings': 'Site Settings', '/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log', '/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Bot Activity', '/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot', '/admin/discord-bot': 'Discord Bot',
'/admin/auth-providers': 'Authentication', '/admin/auth-providers': 'Authentication',
'/admin/users': 'Users', '/admin/users': 'Users',
@@ -44,7 +107,9 @@ const navBtnBase = {
fontFamily: 'var(--sans)', fontFamily: 'var(--sans)',
fontSize: '0.92rem', fontSize: '0.92rem',
textDecoration: 'none', textDecoration: 'none',
display: 'block', display: 'flex',
alignItems: 'center',
gap: 10,
transition: 'background .15s,color .15s', transition: 'background .15s,color .15s',
} }
@@ -62,11 +127,40 @@ export default function AdminLayout() {
// Moderators only get the moderation section + their own account security. // Moderators only get the moderation section + their own account security.
const isModerator = user?.role === 'moderator' const isModerator = user?.role === 'moderator'
const navItems = NAV.filter((n) => { const visible = (item) => {
if (n.roles && !n.roles.includes(user?.role)) return false if (item.roles && !item.roles.includes(user?.role)) return false
if (isModerator) return n.to === '/admin/moderation' || n.to === '/admin/account' if (isModerator) return item.to === '/admin/moderation' || item.to === '/admin/account'
return true return true
}
// Drop items the current role can't see, then drop any now-empty group so an
// empty category header never renders.
const navGroups = NAV
.map((g) => ({ ...g, items: g.items.filter(visible) }))
.filter((g) => g.items.length > 0)
// Accordion: track which titled categories are collapsed. Persist across
// reloads; default all-open. The group holding the active route auto-opens.
const [collapsed, setCollapsed] = useState(() => {
try {
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {}
} catch {
return {}
}
}) })
const toggleGroup = (title) => {
setCollapsed((prev) => {
const next = { ...prev, [title]: !prev[title] }
try {
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next))
} catch {
/* private mode / quota — collapse is non-essential */
}
return next
})
}
const activeGroupTitle = navGroups.find((g) =>
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
)?.title
// Confine a moderator who deep-links (or is redirected to the index) to a page // Confine a moderator who deep-links (or is redirected to the index) to a page
// outside their remit — the API would 403 anyway, so send them to their home. // outside their remit — the API would 403 anyway, so send them to their home.
@@ -118,12 +212,14 @@ export default function AdminLayout() {
</div> </div>
</div> </div>
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}> <nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
{navItems.map((n) => ( {navGroups.map((group, gi) => {
const links = group.items.map((n) => (
<NavLink <NavLink
key={n.to} key={n.to}
to={n.to} to={n.to}
end={n.end} end={n.end}
className="admin-nav-link"
style={({ isActive }) => ({ style={({ isActive }) => ({
...navBtnBase, ...navBtnBase,
background: isActive ? 'var(--blue)' : 'transparent', background: isActive ? 'var(--blue)' : 'transparent',
@@ -131,9 +227,56 @@ export default function AdminLayout() {
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`, borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
})} })}
> >
{n.label} {n.icon && <n.icon />}
<span>{n.label}</span>
</NavLink> </NavLink>
))} ))
// Untitled groups (Dashboard, Account) render their links directly.
if (!group.title) {
return (
<div key={`g${gi}`} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{links}
</div>
)
}
// Titled groups get a collapsible header. The group with the active
// route stays open regardless of the stored collapse preference.
const isOpen = group.title === activeGroupTitle || !collapsed[group.title]
return (
<div key={group.title} className="admin-nav-group">
<button
type="button"
className="admin-nav-head sans"
onClick={() => toggleGroup(group.title)}
aria-expanded={isOpen}
>
<span>{group.title}</span>
<svg
className="admin-nav-chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }}
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isOpen && (
<div className="admin-nav-items" style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{links}
</div>
)}
</div>
)
})}
</nav> </nav>
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}> <div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>

View File

@@ -8,6 +8,7 @@ import { api } from '../../api/client.js'
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with. // Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
const SSO_ERRORS = { const SSO_ERRORS = {
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.', not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
disabled: 'This account is not active. Contact an administrator.',
denied: 'Sign-in was cancelled.', denied: 'Sign-in was cancelled.',
unavailable: 'That sign-in method is not available right now.', unavailable: 'That sign-in method is not available right now.',
bad_state: 'Your sign-in session expired. Please try again.', bad_state: 'Your sign-in session expired. Please try again.',
@@ -35,6 +36,9 @@ export default function AdminLogin() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const dest = location.state?.from?.pathname || '/admin' const dest = location.state?.from?.pathname || '/admin'
// A player who signs in here belongs in the player portal, not the admin shell
// (the admin API 403s them anyway). Staff go to their intended admin dest.
const destFor = (u) => (u && u.role === 'player' ? '/account' : dest)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -54,9 +58,10 @@ export default function AdminLogin() {
const [providers, setProviders] = useState([]) const [providers, setProviders] = useState([])
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || '' const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
// Already signed in → go straight to the panel. // Already signed in → go straight to the right home for the role.
useEffect(() => { useEffect(() => {
if (user) navigate(dest, { replace: true }) if (user) navigate(destFor(user), { replace: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, dest, navigate]) }, [user, dest, navigate])
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP // The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
@@ -101,7 +106,7 @@ export default function AdminLogin() {
setBusy(false) setBusy(false)
return return
} }
navigate(dest, { replace: true }) navigate(destFor(data.user), { replace: true })
} catch (err) { } catch (err) {
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.') setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
setBusy(false) setBusy(false)
@@ -117,8 +122,8 @@ export default function AdminLogin() {
const { returnTo } = await ssoLoginTotp(code) const { returnTo } = await ssoLoginTotp(code)
navigate(returnTo || '/admin', { replace: true }) navigate(returnTo || '/admin', { replace: true })
} else { } else {
await loginTotp(challenge, code) const u = await loginTotp(challenge, code)
navigate(dest, { replace: true }) navigate(destFor(u), { replace: true })
} }
} catch (err) { } catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message) const expired = err.status === 401 && /expired/i.test(err.message)

View File

@@ -0,0 +1,243 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
// captures a refresh token server-side — the token is write-only over the API
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
// requires the Google provider to be configured on the Authentication page first.
const STATUS_COLOR = {
connected: '#7fd0a4',
error: '#d98b84',
unconfigured: 'var(--muted)',
}
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
const ERROR_TEXT = {
denied: 'Google sign-in was cancelled or denied.',
bad_state: 'The connect session expired. Please try again.',
no_client: 'The Google OAuth client is not configured.',
no_refresh_token:
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
no_email: 'Could not read the Gmail address from Google.',
error: 'Could not connect the Gmail account. Please try again.',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
{config.status || 'unconfigured'}
</span>
</div>
{config.senderEmail && (
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: 'var(--ink)' }}>
Sending as <strong>{config.senderEmail}</strong>
</p>
)}
{config.statusDetail && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
)}
{config.lastVerifiedAt && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Last verified: {new Date(config.lastVerifiedAt).toLocaleString()}
</p>
)}
</div>
)
}
export default function EmailDelivery() {
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [senderName, setSenderName] = useState('')
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState('')
const [msg, setMsg] = useState('')
const [actionError, setActionError] = useState('')
const [banner, setBanner] = useState(null) // { kind: 'ok'|'err', text }
const load = useCallback(async (seedForm = false) => {
try {
const c = await api.admin.getEmailConfig()
setConfig(c)
if (seedForm) {
setSenderName(c.senderName || '')
setEnabled(c.enabled)
}
return c
} catch {
setError('Could not load email settings.')
return null
}
}, [])
// 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('')
}
}
async function save() {
setBusy('save')
setMsg('')
setActionError('')
try {
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
setConfig(saved)
setMsg('Saved.')
} catch (err) {
setActionError(err.message || 'Could not save.')
} finally {
setBusy('')
}
}
async function sendTest() {
setBusy('test')
setMsg('')
setActionError('')
try {
const r = await api.admin.testEmail()
setMsg(`Test email sent to ${r.to}.`)
await load()
} catch (err) {
setActionError(err.message || 'Could not send the test email.')
} finally {
setBusy('')
}
}
async function disconnect() {
setBusy('disconnect')
setMsg('')
setActionError('')
try {
const c = await api.admin.disconnectEmail()
setConfig(c)
setEnabled(false)
setMsg('Disconnected.')
} catch (err) {
setActionError(err.message || 'Could not disconnect.')
} finally {
setBusy('')
}
}
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
if (!config) return null
const connected = config.hasRefreshToken
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
<div>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
Sends the contact form through Gmail over OAuth2, delivered to the
<strong> Contact email</strong> above. Reuses the Google authentication
client configure that on the Authentication page first.
</p>
</div>
{banner && (
<div
className="sans"
style={{
fontSize: '0.85rem',
borderRadius: 8,
padding: '10px 12px',
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
}}
>
{banner.text}
</div>
)}
<StatusPanel config={config} />
{!config.googleConfigured && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
The Google authentication provider needs a client ID and secret before
you can connect a Gmail account.
</p>
)}
{!connected ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
</button>
</div>
) : (
<>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enable email sending
</label>
<label style={{ display: 'block' }}>
<span className="field-label">From display name (optional)</span>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="input"
autoComplete="off"
placeholder="UOMysticmoon"
/>
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
{busy === 'save' ? 'Saving…' : 'Save changes'}
</button>
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
{busy === 'test' ? 'Sending…' : 'Send test'}
</button>
<button onClick={connect} disabled={busy === 'connect'} className="pill">
Reconnect
</button>
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
Disconnect
</button>
</div>
</>
)}
<div style={{ minHeight: 18 }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{actionError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{actionError}</span>}
</div>
</section>
)
}

View File

@@ -0,0 +1,453 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import Modal from '../../../components/Modal.jsx'
import { Loading } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import '../../../blocks/index.js' // registers all block types
import { listBlocks, getBlock, makeBlockId } from '../../../blocks/registry.js'
import { SelectField, TextField, TextAreaField } from '../../../blocks/editorKit.jsx'
const LAYOUTS = [
['default', 'Default'],
['full_width', 'Full width'],
['landing', 'Landing'],
]
const NAV_GROUPS = [
['', 'None'],
['main', 'Main nav'],
['footer', 'Footer'],
['account', 'Account'],
['hidden', 'Hidden'],
]
const EMPTY = {
title: '',
slug: '',
status: 'draft',
blocks: [],
metadata: { seoTitle: '', metaDescription: '', ogImage: '', canonicalUrl: '', robots: '' },
settings: { layout: 'default', showInNav: false, navGroup: '', navOrder: null, protected: false },
}
// Map an API page (grouped shape) into local editable form state.
function toForm(page) {
return {
title: page.title || '',
slug: page.slug || '',
status: page.status || 'draft',
blocks: Array.isArray(page.blocks) ? page.blocks : [],
metadata: { ...EMPTY.metadata, ...cleanNulls(page.metadata) },
settings: {
layout: page.settings?.layout || 'default',
showInNav: Boolean(page.settings?.showInNav),
navGroup: page.settings?.navGroup || '',
navOrder: page.settings?.navOrder ?? null,
protected: Boolean(page.settings?.protected),
},
}
}
function cleanNulls(obj) {
const out = {}
for (const [k, v] of Object.entries(obj || {})) out[k] = v == null ? '' : v
return out
}
export default function PageBuilder() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const [form, setForm] = useState(EMPTY)
const [protectedNow, setProtectedNow] = useState(false) // server truth, edit mode
const [loading, setLoading] = useState(isEdit)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [details, setDetails] = useState([]) // block validation errors
const [notice, setNotice] = useState('')
const [tab, setTab] = useState('content')
const [pwModal, setPwModal] = useState(false)
const [dragIndex, setDragIndex] = useState(null)
const palette = useMemo(() => listBlocks(), [])
useEffect(() => {
if (!isEdit) return
let active = true
setLoading(true)
api.admin
.getPage(id)
.then((page) => {
if (!active) return
setForm(toForm(page))
setProtectedNow(Boolean(page.settings?.protected))
setLoading(false)
})
.catch((err) => {
if (!active) return
setError(err.message || 'Could not load the page.')
setLoading(false)
})
return () => {
active = false
}
}, [id, isEdit])
// ── Block operations ────────────────────────────────────────────────
const addBlock = useCallback((type) => {
const def = getBlock(type)
if (!def) return
const block = { id: makeBlockId(), type, version: def.version, visible: true, props: def.defaults() }
setForm((f) => ({ ...f, blocks: [...f.blocks, block] }))
}, [])
const updateBlock = useCallback((blockId, nextProps) => {
setForm((f) => ({
...f,
blocks: f.blocks.map((b) => (b.id === blockId ? { ...b, props: nextProps } : b)),
}))
}, [])
const toggleVisible = useCallback((blockId) => {
setForm((f) => ({
...f,
blocks: f.blocks.map((b) => (b.id === blockId ? { ...b, visible: b.visible === false } : b)),
}))
}, [])
const removeBlock = useCallback((blockId) => {
setForm((f) => ({ ...f, blocks: f.blocks.filter((b) => b.id !== blockId) }))
}, [])
const moveBlock = useCallback((from, to) => {
setForm((f) => {
if (to < 0 || to >= f.blocks.length) return f
const next = [...f.blocks]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
return { ...f, blocks: next }
})
}, [])
function onDrop(index) {
if (dragIndex === null || dragIndex === index) return setDragIndex(null)
moveBlock(dragIndex, index)
setDragIndex(null)
}
// ── Form field setters ──────────────────────────────────────────────
const setField = (k) => (v) => setForm((f) => ({ ...f, [k]: v }))
const setMeta = (k) => (v) => setForm((f) => ({ ...f, metadata: { ...f.metadata, [k]: v } }))
const setSetting = (k) => (v) => setForm((f) => ({ ...f, settings: { ...f.settings, [k]: v } }))
// Serialize local state into an API payload. Empty metadata strings become
// null; navGroup '' becomes null.
function payload() {
const metadata = {}
for (const [k, v] of Object.entries(form.metadata)) metadata[k] = v === '' ? null : v
const settings = {
layout: form.settings.layout,
showInNav: Boolean(form.settings.showInNav),
navGroup: form.settings.navGroup === '' ? null : form.settings.navGroup,
navOrder: form.settings.navOrder === '' || form.settings.navOrder == null ? null : Number(form.settings.navOrder),
}
return { title: form.title.trim(), status: form.status, blocks: form.blocks, metadata, settings }
}
async function save({ silent } = {}) {
setBusy(true)
setError('')
setDetails([])
setNotice('')
try {
if (isEdit) {
await api.admin.updatePage(id, payload())
if (!silent) setNotice('Saved.')
} else {
if (!form.slug.trim()) throw new Error('A slug is required.')
const created = await api.admin.createPage({ slug: form.slug.trim(), ...payload() })
navigate(`/admin/pages/${created.id}`, { replace: true })
}
} catch (err) {
setError(err.message || 'Could not save the page.')
if (err.body?.details) setDetails(err.body.details)
} finally {
setBusy(false)
}
}
async function togglePublish() {
const next = form.status === 'published' ? 'draft' : 'published'
setForm((f) => ({ ...f, status: next }))
// Persist immediately (edit mode) so the status change isn't lost.
if (isEdit) {
setBusy(true)
setError('')
try {
await api.admin.updatePage(id, { ...payload(), status: next })
setNotice(next === 'published' ? 'Published.' : 'Unpublished.')
} catch (err) {
setError(err.message || 'Could not change status.')
} finally {
setBusy(false)
}
}
}
async function protectPage() {
setBusy(true)
setError('')
try {
await api.admin.updatePage(id, { settings: { protected: true } })
setProtectedNow(true)
setForm((f) => ({ ...f, settings: { ...f.settings, protected: true } }))
setNotice('Page protected.')
} catch (err) {
setError(err.message || 'Could not protect the page.')
} finally {
setBusy(false)
}
}
async function unprotectPage(password) {
setBusy(true)
setError('')
try {
await api.admin.unprotectPage(id, password)
setProtectedNow(false)
setForm((f) => ({ ...f, settings: { ...f.settings, protected: false } }))
setPwModal(false)
setNotice('Protection removed.')
} catch (err) {
setError(err.message || 'Could not unprotect the page.')
} finally {
setBusy(false)
}
}
async function preview() {
setError('')
try {
const { token } = await api.admin.createPagePreview(id)
window.open(`/preview/${id}/${token}`, '_blank', 'noopener')
} catch (err) {
setError(err.message || 'Could not create a preview link.')
}
}
async function remove() {
if (!confirm('Delete this page? This cannot be undone.')) return
setBusy(true)
setError('')
try {
await api.admin.deletePage(id)
navigate('/admin/pages')
} catch (err) {
setError(err.message || 'Could not delete the page.')
setBusy(false)
}
}
if (loading) return <Loading />
const published = form.status === 'published'
return (
<section>
{/* Toolbar */}
<div className="pb-toolbar">
<button className="pill" onClick={() => navigate('/admin/pages')}> Pages</button>
<span className={`badge ${published ? 'badge-pub' : 'badge-draft'}`}>{published ? 'Published' : 'Draft'}</span>
<div style={{ flex: 1 }} />
{isEdit && (
<button className="pill" onClick={preview} disabled={busy}>Preview</button>
)}
{isEdit && (
<button className="pill" onClick={togglePublish} disabled={busy}>
{published ? 'Unpublish' : 'Publish'}
</button>
)}
<button className="btn btn-primary btn-sq" onClick={() => save()} disabled={busy}>
{busy ? 'Saving…' : isEdit ? 'Save' : 'Create'}
</button>
</div>
{error && (
<div className="pb-error sans">
{error}
{details.length > 0 && (
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{details.map((d, i) => <li key={i}>{d}</li>)}
</ul>
)}
</div>
)}
{notice && <div className="pb-notice sans">{notice}</div>}
{/* Title + slug */}
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', margin: '16px 0' }}>
<label style={{ flex: '2 1 320px' }}>
<span className="field-label">Title</span>
<input className="input" value={form.title} onChange={(e) => setField('title')(e.target.value)} />
</label>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Slug {isEdit && '(fixed)'}</span>
<input
className="input"
value={form.slug}
disabled={isEdit}
placeholder="my-page"
onChange={(e) => setField('slug')(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
/>
</label>
</div>
{/* Tabs */}
<div className="pb-tabs">
<button className={`pb-tab ${tab === 'content' ? 'is-active' : ''}`} onClick={() => setTab('content')}>Content</button>
<button className={`pb-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Settings &amp; SEO</button>
</div>
{tab === 'content' && (
<>
<div className="pb-palette">
<span className="field-label" style={{ margin: '0 6px 0 0' }}>Add block</span>
{palette.map((b) => (
<button key={b.type} className="pill" onClick={() => addBlock(b.type)} disabled={busy}>
<span aria-hidden style={{ marginRight: 6 }}>{b.icon}</span>{b.label}
</button>
))}
</div>
<div className="pb-canvas">
{form.blocks.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', padding: 30 }}>
No blocks yet add one from the palette above.
</p>
)}
{form.blocks.map((block, i) => {
const def = getBlock(block.type)
const Editor = def?.editor
const hidden = block.visible === false
return (
<div
key={block.id}
className={`pb-block-card ${hidden ? 'is-hidden' : ''} ${dragIndex === i ? 'is-dragging' : ''}`}
draggable
onDragStart={() => setDragIndex(i)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(i)}
onDragEnd={() => setDragIndex(null)}
>
<div className="pb-block-head">
<span className="pb-drag" title="Drag to reorder"></span>
<strong className="sans">{def?.label || block.type}</strong>
<div style={{ flex: 1 }} />
<button className="pill pb-mini" title={hidden ? 'Show' : 'Hide'} onClick={() => toggleVisible(block.id)}>
{hidden ? '🙈' : '👁'}
</button>
<button className="pill pb-mini" disabled={i === 0} onClick={() => moveBlock(i, i - 1)} title="Move up"></button>
<button className="pill pb-mini" disabled={i === form.blocks.length - 1} onClick={() => moveBlock(i, i + 1)} title="Move down"></button>
<button className="pill pb-mini" onClick={() => removeBlock(block.id)} title="Remove"></button>
</div>
<div className="pb-block-body">
{Editor ? (
<Editor props={block.props || {}} onChange={(p) => updateBlock(block.id, p)} />
) : (
<p className="sans dim">Unknown block type: {block.type}</p>
)}
</div>
</div>
)
})}
</div>
</>
)}
{tab === 'settings' && (
<div className="pb-settings">
<div className="card" style={{ padding: 18 }}>
<p className="card-kicker">SEO &amp; metadata</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
<TextField label="SEO title" value={form.metadata.seoTitle} maxLength={200} onChange={setMeta('seoTitle')} hint="Overrides the page title in the browser tab / search results." />
<TextAreaField label="Meta description" value={form.metadata.metaDescription} rows={2} maxLength={400} onChange={setMeta('metaDescription')} />
<TextField label="OG image URL" value={form.metadata.ogImage} maxLength={500} onChange={setMeta('ogImage')} />
<TextField label="Canonical URL" value={form.metadata.canonicalUrl} maxLength={500} onChange={setMeta('canonicalUrl')} />
<TextField label="Robots" value={form.metadata.robots} maxLength={100} onChange={setMeta('robots')} placeholder="index,follow" />
</div>
</div>
<div className="card" style={{ padding: 18 }}>
<p className="card-kicker">Layout &amp; navigation</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
<SelectField label="Layout" value={form.settings.layout} onChange={setSetting('layout')} options={LAYOUTS} />
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={form.settings.showInNav} onChange={(e) => setSetting('showInNav')(e.target.checked)} />
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Show in navigation</span>
</label>
<SelectField label="Nav group" value={form.settings.navGroup} onChange={setSetting('navGroup')} options={NAV_GROUPS} />
<TextField label="Nav order" value={form.settings.navOrder ?? ''} onChange={(v) => setSetting('navOrder')(v === '' ? null : v.replace(/[^0-9]/g, ''))} hint="Lower numbers appear first." />
</div>
</div>
<div className="card" style={{ padding: 18 }}>
<p className="card-kicker">Protection &amp; danger zone</p>
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 8 }}>
A protected page cant be deleted and its protection can only be removed by re-entering your password.
</p>
{!isEdit && <p className="sans dim" style={{ fontSize: '0.82rem' }}>Save the page first to manage protection.</p>}
{isEdit && (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 10 }}>
{protectedNow ? (
<button className="pill" onClick={() => setPwModal(true)} disabled={busy}>🔓 Remove protection</button>
) : (
<button className="pill" onClick={protectPage} disabled={busy}>🔒 Protect page</button>
)}
<button className="pill pb-danger" onClick={remove} disabled={busy || protectedNow} title={protectedNow ? 'Unprotect first' : 'Delete'}>
Delete page
</button>
</div>
)}
</div>
</div>
)}
{pwModal && (
<UnprotectModal onCancel={() => setPwModal(false)} onConfirm={unprotectPage} busy={busy} error={error} />
)}
</section>
)
}
function UnprotectModal({ onCancel, onConfirm, busy, error }) {
const [pw, setPw] = useState('')
return (
<Modal
title="Confirm your password"
onClose={onCancel}
width={420}
footer={
<>
<button className="pill" onClick={onCancel} disabled={busy}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={() => onConfirm(pw)} disabled={busy || !pw}>
{busy ? 'Verifying…' : 'Remove protection'}
</button>
</>
}
>
<p className="sans dim" style={{ marginTop: 0, fontSize: '0.88rem' }}>
Removing protection is a sensitive change re-enter your account password to continue.
</p>
<input
type="password"
className="input"
autoFocus
value={pw}
onChange={(e) => setPw(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && pw && onConfirm(pw)}
placeholder="Password"
/>
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.85rem', marginBottom: 0 }}>{error}</p>}
</Modal>
)
}

View File

@@ -0,0 +1,91 @@
import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { shortDate } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// List of CMS pages. Create/edit open the full-page block builder; the builder
// owns save/delete/publish so this view is read-only navigation.
export default function PagesAdmin() {
const navigate = useNavigate()
const [tick] = useState(0)
const { loading, error, data } = useAsync(() => api.admin.listPages(), [tick])
const pages = data || []
const openNew = useCallback(() => navigate('/admin/pages/new'), [navigate])
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14, marginBottom: 18 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
Compose pages from blocks. A published page is live at <code>/its-slug</code>.
</p>
<button onClick={openNew} className="btn btn-primary btn-sq">
+ New page
</button>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load pages." />}
{!loading && !error && (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Title</th>
<th className="adm-th">Slug</th>
<th className="adm-th">Status</th>
<th className="adm-th">Updated</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{pages.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No pages yet create your first one.
</td>
</tr>
)}
{pages.map((p) => (
<tr key={p.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{p.title}
{p.protected && (
<span title="Protected" style={{ marginLeft: 8 }}>🔒</span>
)}
</td>
<td className="adm-td dim">/{p.slug}</td>
<td className="adm-td">
<span className={`badge ${p.status === 'published' ? 'badge-pub' : 'badge-draft'}`}>
{p.status === 'published' ? 'Published' : 'Draft'}
</span>
</td>
<td className="adm-td dim">{shortDate(p.updatedAt)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
{p.status === 'published' && (
<a
className="link-accent"
href={`/${p.slug}`}
target="_blank"
rel="noreferrer"
style={{ marginRight: 14 }}
>
View
</a>
)}
<span className="link-accent" onClick={() => navigate(`/admin/pages/${p.id}`)}>
Edit
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx' import { useSite } from '../../../contexts/SiteContext.jsx'
import EmailDelivery from './EmailDelivery.jsx'
// Editable settings shown on this screen (key -> label + control type). // Editable settings shown on this screen (key -> label + control type).
const FIELDS = [ const FIELDS = [
@@ -9,7 +10,23 @@ const FIELDS = [
{ key: 'homepage_teaser', label: 'Homepage teaser', long: true }, { key: 'homepage_teaser', label: 'Homepage teaser', long: true },
{ key: 'maintenance_message', label: 'Maintenance message', long: true }, { key: 'maintenance_message', label: 'Maintenance message', long: true },
{ key: 'status_message', label: 'Status message' }, { key: 'status_message', label: 'Status message' },
{ key: 'contact_email', label: 'Contact email' }, {
key: 'contact_email',
label: 'Contact email',
help: 'Where contact-form messages (and test emails) are delivered. Also the address shown when email delivery is unconfigured and the form falls back to a mailto: link.',
},
{
key: 'player_registration',
label: 'Player registration',
help: 'Who can create a player account, and how. Off by default.',
options: [
{ value: 'disabled', label: 'Disabled — no self-registration' },
{ value: 'password', label: 'Password — username + password sign-up' },
{ value: 'sso', label: 'SSO — sign up with a linked provider' },
{ value: 'both', label: 'Both — password and SSO' },
],
fallback: 'disabled',
},
] ]
export default function SettingsAdmin() { export default function SettingsAdmin() {
@@ -28,7 +45,7 @@ export default function SettingsAdmin() {
.then((all) => { .then((all) => {
if (!active) return if (!active) return
const v = {} const v = {}
FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? '')) FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? f.fallback ?? ''))
setValues(v) setValues(v)
setInitial(v) setInitial(v)
}) })
@@ -68,11 +85,24 @@ export default function SettingsAdmin() {
{FIELDS.map((f) => ( {FIELDS.map((f) => (
<label key={f.key} style={{ display: 'block' }}> <label key={f.key} style={{ display: 'block' }}>
<span className="field-label">{f.label}</span> <span className="field-label">{f.label}</span>
{f.long ? ( {f.options ? (
<select value={values[f.key]} onChange={set(f.key)} className="select">
{f.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : f.long ? (
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} /> <textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
) : ( ) : (
<input type="text" value={values[f.key]} onChange={set(f.key)} className="input" /> <input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
)} )}
{f.help && (
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
{f.help}
</span>
)}
</label> </label>
))} ))}
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
@@ -86,6 +116,8 @@ export default function SettingsAdmin() {
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>} {error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div> </div>
</div> </div>
<EmailDelivery />
</section> </section>
) )
} }

View File

@@ -8,6 +8,8 @@ export default function UserEditor({ user, onClose, onSaved }) {
username: user?.username || '', username: user?.username || '',
password: '', password: '',
role: user?.role || 'admin', role: user?.role || 'admin',
status: user?.status || 'active',
email: user?.email || '',
}) })
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -21,12 +23,19 @@ export default function UserEditor({ user, onClose, onSaved }) {
setBusy(true) setBusy(true)
setError('') setError('')
try { try {
const email = form.email.trim() || null
if (isEdit) { if (isEdit) {
const payload = { username: form.username.trim(), role: form.role } const payload = { username: form.username.trim(), role: form.role, status: form.status, email }
if (form.password) payload.password = form.password if (form.password) payload.password = form.password
await api.admin.updateUser(user.id, payload) await api.admin.updateUser(user.id, payload)
} else { } else {
await api.admin.createUser({ username: form.username.trim(), password: form.password, role: form.role }) await api.admin.createUser({
username: form.username.trim(),
password: form.password,
role: form.role,
status: form.status,
email,
})
} }
onSaved() onSaved()
} catch (err) { } catch (err) {
@@ -75,17 +84,41 @@ export default function UserEditor({ user, onClose, onSaved }) {
<input type="text" value={form.username} onChange={set('username')} className="input" autoComplete="off" /> <input type="text" value={form.username} onChange={set('username')} className="input" autoComplete="off" />
</label> </label>
<label> <label>
<span className="field-label">{isEdit ? 'New password (leave blank to keep)' : 'Password'}</span> <span className="field-label">
{isEdit ? 'Reset password (leave blank to keep)' : 'Password'}
</span>
<input type="password" value={form.password} onChange={set('password')} className="input" autoComplete="new-password" /> <input type="password" value={form.password} onChange={set('password')} className="input" autoComplete="new-password" />
{isEdit && (
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Setting a new password here is the supported reset for a player who is locked out. It logs
their other sessions out.
</span>
)}
</label> </label>
<label> <label>
<span className="field-label">Email (optional)</span>
<input type="email" value={form.email} onChange={set('email')} className="input" autoComplete="off" placeholder="player@example.com" />
</label>
<div style={{ display: 'flex', gap: 12 }}>
<label style={{ flex: 1 }}>
<span className="field-label">Role</span> <span className="field-label">Role</span>
<select value={form.role} onChange={set('role')} className="select"> <select value={form.role} onChange={set('role')} className="select">
<option value="admin">admin</option> <option value="admin">admin</option>
<option value="editor">editor</option> <option value="editor">editor</option>
<option value="moderator">moderator</option> <option value="moderator">moderator</option>
<option value="player">player</option>
</select> </select>
</label> </label>
<label style={{ flex: 1 }}>
<span className="field-label">Status</span>
<select value={form.status} onChange={set('status')} className="select">
<option value="active">active</option>
<option value="disabled">disabled</option>
<option value="banned">banned</option>
<option value="pending">pending</option>
</select>
</label>
</div>
</div> </div>
</Modal> </Modal>
) )

View File

@@ -5,7 +5,12 @@ import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import UserEditor from './UserEditor.jsx' import UserEditor from './UserEditor.jsx'
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' } const ROLE_BADGE = {
admin: 'badge-admin',
editor: 'badge-editor',
moderator: 'badge-moderator',
player: 'badge-player',
}
export default function UsersAdmin() { export default function UsersAdmin() {
const [tick, setTick] = useState(0) const [tick, setTick] = useState(0)
@@ -18,7 +23,7 @@ export default function UsersAdmin() {
<section> <section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}> <p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
Manage admin, editor, and moderator accounts Manage admin, editor, moderator, and player accounts
</p> </p>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq"> <button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ Add user + Add user
@@ -35,6 +40,7 @@ export default function UsersAdmin() {
<tr> <tr>
<th className="adm-th">Username</th> <th className="adm-th">Username</th>
<th className="adm-th">Role</th> <th className="adm-th">Role</th>
<th className="adm-th">Status</th>
<th className="adm-th">Last login</th> <th className="adm-th">Last login</th>
<th className="adm-th" /> <th className="adm-th" />
</tr> </tr>
@@ -48,6 +54,14 @@ export default function UsersAdmin() {
<td className="adm-td"> <td className="adm-td">
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span> <span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
</td> </td>
<td className="adm-td">
<span
className="sans"
style={{ fontSize: '0.82rem', color: u.status && u.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
>
{u.status || 'active'}
</span>
</td>
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td> <td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
<td className="adm-td" style={{ textAlign: 'right' }}> <td className="adm-td" style={{ textAlign: 'right' }}>
<span className="link-accent" onClick={() => setEditing(u)}> <span className="link-accent" onClick={() => setEditing(u)}>

View File

@@ -0,0 +1,375 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
// ── Change username ────────────────────────────────────────────────────────
function ChangeUsername({ account, onChanged }) {
const [username, setUsername] = useState(account.username)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function save(e) {
e.preventDefault()
setMsg('')
setError('')
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
setBusy(true)
try {
const { username: next } = await api.player.changeUsername(username.trim())
setMsg('Username updated.')
await onChanged(next)
} catch (err) {
if (err.status === 409) setError('That username is already taken.')
else setError(err.message || 'Could not change your username.')
} finally {
setBusy(false)
}
}
return (
<Section title="Username">
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
<label>
<span className="field-label">Username</span>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="input" autoComplete="username" />
</label>
<div>
<button type="submit" disabled={busy || username.trim() === account.username} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Change username'}
</button>
</div>
<Note msg={msg} error={error} />
</form>
</Section>
)
}
// ── Change / set password ──────────────────────────────────────────────────
function ChangePassword({ account }) {
const hasPassword = account.has_password
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function save(e) {
e.preventDefault()
setMsg('')
setError('')
if (next.length < 8) return setError('New password must be at least 8 characters.')
if (hasPassword && !current) return setError('Enter your current password.')
setBusy(true)
try {
await api.player.changePassword(next, hasPassword ? current : undefined)
setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.')
setCurrent('')
setNext('')
} catch (err) {
setError(err.message || 'Could not change your password.')
} finally {
setBusy(false)
}
}
return (
<Section title={hasPassword ? 'Password' : 'Set a password'}>
{!hasPassword && (
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Your account was created through a linked provider and has no password yet. Set one to also be
able to sign in with a username and password.
</p>
)}
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
{hasPassword && (
<label>
<span className="field-label">Current password</span>
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} className="input" autoComplete="current-password" />
</label>
)}
<label>
<span className="field-label">New password</span>
<input type="password" value={next} onChange={(e) => setNext(e.target.value)} className="input" autoComplete="new-password" />
</label>
<div>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : hasPassword ? 'Change password' : 'Set password'}
</button>
</div>
<Note msg={msg} error={error} />
</form>
</Section>
)
}
// ── Two-factor (TOTP) ──────────────────────────────────────────────────────
function TwoFactor({ account, reload }) {
const enabled = account.totp_enabled
const [setup, setSetup] = useState(null)
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function begin() {
setBusy(true); setMsg(''); setError('')
try {
setSetup(await api.player.totpSetup())
setCode('')
} catch (err) {
setError(err.message || 'Could not start setup.')
} finally {
setBusy(false)
}
}
async function confirm() {
setBusy(true); setMsg(''); setError('')
try {
await api.player.totpEnable(code.trim())
setSetup(null); setCode(''); setMsg('Two-factor is now enabled.')
await reload()
} catch (err) {
setError(err.message || 'Could not enable two-factor.')
} finally {
setBusy(false)
}
}
async function disable() {
setBusy(true); setMsg(''); setError('')
try {
await api.player.totpDisable(code.trim())
setCode(''); setMsg('Two-factor has been disabled.')
await reload()
} catch (err) {
setError(err.message || 'Could not disable two-factor.')
} finally {
setBusy(false)
}
}
return (
<Section title="Two-factor authentication">
<div className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderRadius: 999, border: '1px solid var(--line)', fontSize: '0.82rem', color: enabled ? '#7fd0a4' : 'var(--muted)', marginBottom: 18 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: enabled ? '#7fd0a4' : 'var(--dim)' }} />
{enabled ? 'Enabled' : 'Not enabled'}
</div>
{!enabled && !setup && (
<div>
<button onClick={begin} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Preparing…' : 'Set up two-factor'}
</button>
</div>
)}
{!enabled && setup && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
Scan this QR code with your authenticator app, then enter the current 6-digit code.
</p>
<img src={setup.qr} alt="TOTP QR code" width={180} height={180} style={{ borderRadius: 8, background: '#fff', padding: 8, alignSelf: 'flex-start' }} />
<label style={{ display: 'block', maxWidth: 220 }}>
<span className="field-label">Verification code</span>
<input type="text" inputMode="numeric" autoComplete="one-time-code" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={confirm} disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
{busy ? 'Enabling…' : 'Confirm & enable'}
</button>
<button onClick={() => setSetup(null)} disabled={busy} className="pill">Cancel</button>
</div>
</div>
)}
{enabled && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
Enter a current code from your authenticator to turn two-factor off.
</p>
<label style={{ display: 'block', maxWidth: 220 }}>
<span className="field-label">Verification code</span>
<input type="text" inputMode="numeric" autoComplete="one-time-code" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
</label>
<div>
<button onClick={disable} disabled={busy || !code.trim()} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
{busy ? 'Disabling…' : 'Disable two-factor'}
</button>
</div>
</div>
)}
<Note msg={msg} error={error} />
</Section>
)
}
// ── Linked SSO identities ──────────────────────────────────────────────────
function LinkedAccounts() {
const [linked, setLinked] = useState(null)
const [available, setAvailable] = useState([])
const [error, setError] = useState('')
const banner = (() => {
const q = new URLSearchParams(window.location.search)
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
return null
})()
const load = useCallback(async () => {
try {
const [ids, avail] = await Promise.all([
api.player.linkedIdentities(),
api.authProviders().catch(() => []),
])
setLinked(ids)
setAvailable(Array.isArray(avail) ? avail : [])
} catch {
setError('Could not load linked accounts.')
}
}, [])
useEffect(() => { load() }, [load])
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
async function unlink(provider) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
try {
await api.player.unlinkIdentity(provider)
await load()
} catch (err) {
setError(err.message || 'Could not unlink.')
}
}
if (error) return <ErrorState message={error} />
if (!linked) return null
const linkedIds = new Set(linked.map((i) => i.provider))
const linkable = available.filter((p) => !linkedIds.has(p.id))
return (
<Section title="Linked accounts">
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Link a Google, Discord, or other provider so you can sign in with it.
</p>
{banner && (
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
)}
{linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{linked.map((i) => (
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
<ProviderIcon icon={iconFor(i.provider)} size={20} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
</div>
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
</div>
))}
</div>
)}
{linkable.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
{linkable.map((p) => (
<button key={p.id} onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link?returnTo=${encodeURIComponent('/account')}`)} className="btn" style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
<ProviderIcon icon={p.icon} size={18} />
</span>
Link {p.name}
</button>
))}
</div>
)}
{linked.length === 0 && linkable.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.86rem' }}>No SSO providers are enabled.</p>
)}
</Section>
)
}
// ── Shared bits ────────────────────────────────────────────────────────────
function Section({ title, children }) {
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
{children}
</section>
)
}
function Note({ msg, error }) {
if (!msg && !error) return null
return <p className="sans" style={{ margin: '4px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>{error || msg}</p>
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerAccount() {
const { logout, refresh } = useAuth()
const [account, setAccount] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
setAccount(await api.player.getAccount())
} catch {
setError('Could not load your account.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// After a username change: reload local account + refresh the auth context so
// the header reflects the new name.
const onUsernameChanged = useCallback(async () => {
await Promise.all([load(), refresh()])
}, [load, refresh])
return (
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
<header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '18px 20px', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<MoonDot size={12} glow={0.5} />
<span className="display" style={{ color: 'var(--head)', fontSize: '1.1rem', letterSpacing: '0.04em' }}>
My Account
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
Site
</Link>
<button onClick={logout} className="pill">Sign out</button>
</div>
</header>
<div style={{ maxWidth: 620, margin: '0 auto', padding: '10px 20px 60px' }}>
{loading && <Loading />}
{error && <ErrorState message={error} />}
{!loading && !error && account && (
<>
<div style={{ paddingTop: 24 }}>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
{account.email ? ` · ${account.email}` : ''}
</p>
</div>
<ChangeUsername account={account} onChanged={onUsernameChanged} />
<ChangePassword account={account} />
<TwoFactor account={account} reload={load} />
<LinkedAccounts />
</>
)}
</div>
</main>
)
}

View File

@@ -0,0 +1,208 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
// Friendly copy for the ?sso_error codes the SSO callback can bounce back with.
const SSO_ERRORS = {
not_linked:
'That account is not linked to a player. Enable SSO sign-up, or sign in with a password and link it under your account.',
disabled: 'This account is not active. Contact an administrator.',
denied: 'Sign-in was cancelled.',
unavailable: 'That sign-in method is not available right now.',
bad_state: 'Your sign-in session expired. Please try again.',
error: 'Could not complete sign-in. Please try again.',
}
export default function PlayerLogin() {
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/account'
// A staff member who signs in here belongs in the admin shell, not the portal.
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [company, setCompany] = useState('') // honeypot — must stay empty
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false)
const [providers, setProviders] = useState([])
const [canRegister, setCanRegister] = useState(false)
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
// Already signed in → go straight to the right home for the role.
useEffect(() => {
if (user) navigate(destFor(user), { replace: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, dest, navigate])
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1.
useEffect(() => {
if (new URLSearchParams(location.search).get('sso_totp')) {
setStage('totp')
setSsoTotp(true)
}
}, [location.search])
// SSO providers (for buttons) + whether password registration is open.
useEffect(() => {
let active = true
api
.authProviders()
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
.catch(() => active && setProviders([]))
api
.publicSettings()
.then((s) => active && setCanRegister(Boolean(s?.registration?.password)))
.catch(() => {})
return () => {
active = false
}
}, [])
function startSso(provider) {
// Always return into the player portal so the callback lands on /account*.
const q = `?returnTo=${encodeURIComponent(dest.startsWith('/account') ? dest : '/account')}`
window.location.assign(provider.loginUrl + q)
}
async function onSubmit(e) {
e.preventDefault()
setError('')
setBusy(true)
try {
const data = await login(username, password, { company })
if (data.totpRequired) {
setChallenge(data.challenge)
setStage('totp')
setBusy(false)
return
}
navigate(destFor(data.user), { replace: true })
} catch (err) {
if (err.status === 403) setError('This account is not active. Contact an administrator.')
else setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
setBusy(false)
}
}
async function onSubmitTotp(e) {
e.preventDefault()
setError('')
setBusy(true)
try {
if (ssoTotp) {
const { returnTo } = await ssoLoginTotp(code)
navigate(returnTo || '/account', { replace: true })
} else {
const u = await loginTotp(challenge, code)
navigate(destFor(u), { replace: true })
}
} catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message)
setError(expired ? 'Your verification session expired. Please sign in again.' : 'Invalid verification code.')
setBusy(false)
if (expired) {
setStage('creds')
setSsoTotp(false)
}
}
}
return (
<PlayerShell
subtitle="Player sign-in"
footer={
canRegister && (
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
New here?{' '}
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Create an account
</Link>
</p>
)
}
>
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
{stage === 'creds' ? (
<>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Username</span>
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
</label>
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Password</span>
<input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
</label>
<div style={honeypotStyle} aria-hidden="true">
<label>
Company
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
</label>
</div>
</>
) : (
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Authentication code</span>
<input type="text" inputMode="numeric" autoComplete="one-time-code" autoFocus placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
Enter the code from your authenticator app.
</span>
</label>
)}
{(error || (stage === 'creds' && ssoError)) && (
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
{error || ssoError}
</p>
)}
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
</button>
{stage === 'creds' && providers.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{providers.map((p) => (
<button key={p.id} type="button" onClick={() => startSso(p)} className="btn" style={ssoBtnStyle}>
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
<ProviderIcon icon={p.icon} size={18} />
</span>
Continue with {p.name}
</button>
))}
</div>
</div>
)}
</form>
</PlayerShell>
)
}
const ssoBtnStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
width: '100%',
borderRadius: 8,
padding: 11,
border: '1px solid var(--line)',
background: 'rgba(255,255,255,0.04)',
color: 'var(--ink)',
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
export default function PlayerRegister() {
const { user, register } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [company, setCompany] = useState('') // honeypot — must stay empty
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Which methods are enabled (derived, from /public/settings). null = loading.
const [avail, setAvail] = useState(null)
const [providers, setProviders] = useState([])
useEffect(() => {
if (user && user.role === 'player') navigate('/account', { replace: true })
}, [user, navigate])
useEffect(() => {
let active = true
api
.publicSettings()
.then((s) => active && setAvail(s?.registration || { password: false, sso: false }))
.catch(() => active && setAvail({ password: false, sso: false }))
api
.authProviders()
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
.catch(() => active && setProviders([]))
return () => {
active = false
}
}, [])
function startSso(provider) {
window.location.assign(provider.loginUrl + `?returnTo=${encodeURIComponent('/account')}`)
}
async function onSubmit(e) {
e.preventDefault()
setError('')
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await register(username.trim(), password, { email: email.trim() || undefined, company })
navigate('/account', { replace: true })
} catch (err) {
if (err.status === 409) setError('That username is already taken.')
else if (err.status === 403) setError('Registration is not open right now.')
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
else setError('Could not create your account right now.')
setBusy(false)
}
}
const closed = avail && !avail.password && !avail.sso
return (
<PlayerShell
subtitle="Create a player account"
footer={
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
Already have an account?{' '}
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Sign in
</Link>
</p>
}
>
{avail === null ? (
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
<span className="spin" />
</div>
) : closed ? (
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem', textAlign: 'center', lineHeight: 1.6 }}>
Self-registration is currently closed. Please check back later.
</p>
) : (
<>
{avail.password && (
<form onSubmit={onSubmit}>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Username</span>
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Password</span>
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
</label>
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Email (optional)</span>
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="player@example.com" />
<span className="sans" style={{ display: 'block', marginTop: 6, color: 'var(--dim)', fontSize: '0.74rem' }}>
Used only for account recovery help. No password-reset emails yet a forgotten password
is reset by an administrator.
</span>
</label>
<div style={honeypotStyle} aria-hidden="true">
<label>
Company
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
</label>
</div>
{error && (
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
{error}
</p>
)}
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
{busy ? 'Creating…' : 'Create account'}
</button>
</form>
)}
{avail.sso && providers.length > 0 && (
<div style={{ marginTop: avail.password ? 20 : 0 }}>
{avail.password && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{providers.map((p) => (
<button key={p.id} type="button" onClick={() => startSso(p)} className="btn" style={ssoBtnStyle}>
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
<ProviderIcon icon={p.icon} size={18} />
</span>
Sign up with {p.name}
</button>
))}
</div>
</div>
)}
</>
)}
</PlayerShell>
)
}
const ssoBtnStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
width: '100%',
borderRadius: 8,
padding: 11,
border: '1px solid var(--line)',
background: 'rgba(255,255,255,0.04)',
color: 'var(--ink)',
}

View File

@@ -0,0 +1,72 @@
import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
const BG =
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
// Centered card layout shared by the player login / register pages. `subtitle`
// labels the card; `footer` is optional content under the card (e.g. cross-links).
export default function PlayerShell({ subtitle, children, footer }) {
return (
<main
style={{
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
padding: '40px 18px',
overflow: 'hidden',
backgroundColor: 'var(--bg-deep)',
backgroundImage: BG,
backgroundPosition: 'center',
backgroundSize: 'cover',
}}
>
<div style={{ width: '100%', maxWidth: 400 }}>
<div style={{ textAlign: 'center', marginBottom: 26 }}>
<div style={{ marginBottom: 14 }}>
<MoonDot size={15} glow={0.55} />
</div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
UOMysticmoon
</h1>
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
{subtitle}
</p>
</div>
<div
style={{
border: '1px solid var(--line)',
borderRadius: 12,
padding: 28,
background: 'linear-gradient(180deg,rgba(25,34,49,0.92),rgba(20,26,33,0.92))',
backdropFilter: 'blur(6px)',
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
}}
>
{children}
</div>
{footer}
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
Back to site
</Link>
</p>
</div>
</main>
)
}
// Off-screen honeypot styling (matches the admin login): present for bots, never
// seen or filled by real users. Name must equal the server HONEYPOT_FIELD.
export const honeypotStyle = {
position: 'absolute',
left: '-9999px',
top: 'auto',
width: '1px',
height: '1px',
opacity: 0,
pointerEvents: 'none',
}

View File

@@ -0,0 +1,56 @@
import { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import '../../blocks/index.js' // registers all block types
import { BlockList } from '../../blocks/BlockRenderer.jsx'
// Renders a CMS page composed of blocks. Two modes:
// - live: /:slug → fetches the published page (staff see drafts)
// - preview: /preview/:id/:token → fetches the current state via a token,
// regardless of publish status (draft-preview links).
export default function CmsPage({ preview = false }) {
const params = useParams()
const { loading, error, data: page } = useAsync(
() => (preview ? api.pagePreview(params.id, params.token) : api.page(params.slug)),
[preview, params.id, params.token, params.slug],
)
// Reflect the page's title + meta description while it's mounted, then restore.
useEffect(() => {
if (!page) return
const prevTitle = document.title
document.title = page.metadata?.seoTitle || page.title || prevTitle
return () => {
document.title = prevTitle
}
}, [page])
const layout = page?.settings?.layout || 'default'
const widthClass = layout === 'full_width' || layout === 'landing' ? 'shell-wide' : 'shell'
return (
<PublicLayout section="website">
<div className={`${widthClass} page-body`} style={{ paddingTop: 40 }}>
{preview && page && (
<div className="page-preview-banner sans">
Preview this is the current draft state and isnt publicly visible.
</div>
)}
{loading && <Loading />}
{error && (
<ErrorState
message={error.status === 404 ? 'That page could not be found.' : 'Could not load this page.'}
/>
)}
{page && (
<article className={`page-blocks page-layout--${layout}`}>
<BlockList blocks={page.blocks} />
</article>
)}
</div>
</PublicLayout>
)
}

View File

@@ -79,6 +79,10 @@ a {
width: min(760px, calc(100% - 32px)); width: min(760px, calc(100% - 32px));
margin: 0 auto; margin: 0 auto;
} }
.shell-wide {
width: min(1280px, calc(100% - 32px));
margin: 0 auto;
}
.page { .page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@@ -618,6 +622,11 @@ button[disabled] {
color: #e0b070; color: #e0b070;
border: 1px solid rgba(224, 176, 112, 0.4); border: 1px solid rgba(224, 176, 112, 0.4);
} }
.badge-player {
background: rgba(126, 196, 156, 0.12);
color: #7ec49c;
border: 1px solid rgba(126, 196, 156, 0.4);
}
/* Action-type badges for the moderation dashboard. */ /* Action-type badges for the moderation dashboard. */
.badge-ban { .badge-ban {
background: rgba(217, 139, 132, 0.16); background: rgba(217, 139, 132, 0.16);
@@ -683,6 +692,47 @@ button[disabled] {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* Admin sidebar — collapsible category sections */
.admin-nav-group {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.admin-nav-head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 14px 4px;
border: 0;
background: transparent;
color: var(--dim);
font-size: 0.66rem;
letter-spacing: 0.14em;
text-transform: uppercase;
cursor: pointer;
transition: color 0.15s;
}
.admin-nav-head:hover {
color: var(--muted);
}
.admin-nav-chev {
transition: transform 0.15s ease;
flex: 0 0 auto;
}
.admin-nav-items {
padding-left: 6px;
}
.admin-nav-link > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-nav-link svg {
flex: 0 0 16px;
}
.wiki-grid { .wiki-grid {
display: grid; display: grid;
grid-template-columns: 230px 1fr; grid-template-columns: 230px 1fr;
@@ -694,3 +744,265 @@ button[disabled] {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ===== CMS page builder — public block rendering ===== */
/* Vertical rhythm between top-level blocks on a rendered page. */
.page-blocks > * + * {
margin-top: 26px;
}
.page-heading {
font-family: var(--serif, Georgia, serif);
color: var(--ink);
line-height: 1.2;
}
.page-divider {
border: none;
border-top: 1px solid var(--line);
margin: 8px 0;
}
/* image block */
.page-image {
margin: 0;
}
.page-image img {
max-width: 100%;
border-radius: 8px;
border: 1px solid var(--line);
display: block;
}
.page-image figcaption {
margin-top: 8px;
color: var(--muted);
font-family: var(--sans);
font-size: 0.82rem;
}
.page-image--center {
text-align: center;
}
.page-image--center img,
.page-image--center figcaption {
margin-left: auto;
margin-right: auto;
}
.page-image--right {
text-align: right;
}
.page-image--right img,
.page-image--right figcaption {
margin-left: auto;
}
.page-image--full img {
width: 100%;
}
/* cta block */
.page-cta-wrap {
display: flex;
}
.page-cta--secondary {
background: transparent;
color: var(--ink);
}
/* quote block */
.page-quote {
margin: 0;
border-left: 3px solid var(--accent);
padding: 4px 0 4px 20px;
}
.page-quote blockquote {
margin: 0;
font-size: 1.15rem;
line-height: 1.5;
color: var(--ink);
font-style: italic;
}
.page-quote figcaption {
margin-top: 8px;
color: var(--muted);
font-family: var(--sans);
font-size: 0.85rem;
}
/* two-column block */
.page-two-column {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.page-column > * + * {
margin-top: 18px;
}
@media (max-width: 720px) {
.page-two-column {
grid-template-columns: 1fr;
}
}
/* ===== CMS page builder — column sub-block editor ===== */
.pb-two-column-editor {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 720px) {
.pb-two-column-editor {
grid-template-columns: 1fr;
}
}
.pb-column-editor {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: var(--panel-flat, transparent);
}
.pb-column-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.pb-add-select {
width: auto;
padding: 6px 10px;
font-size: 0.82rem;
}
.pb-subblock {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
margin-top: 10px;
background: var(--bg);
}
.pb-subblock-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.pb-subblock-actions {
display: flex;
gap: 4px;
}
.pb-mini {
min-width: 28px;
padding: 3px 8px;
font-size: 0.8rem;
line-height: 1;
}
/* ===== CMS page builder — admin canvas ===== */
.pb-toolbar {
display: flex;
align-items: center;
gap: 10px;
position: sticky;
top: 0;
z-index: 5;
padding: 10px 0;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.pb-error {
border: 1px solid #6e3b38;
background: rgba(110, 59, 56, 0.16);
color: #e6a9a3;
border-radius: 8px;
padding: 10px 14px;
margin-top: 14px;
font-size: 0.86rem;
}
.pb-notice {
border: 1px solid var(--accent);
background: var(--blue);
color: var(--accent-bright);
border-radius: 8px;
padding: 8px 14px;
margin-top: 14px;
font-size: 0.86rem;
}
.pb-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--line);
margin-bottom: 18px;
}
.pb-tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font-family: var(--sans);
font-size: 0.9rem;
padding: 10px 16px;
cursor: pointer;
}
.pb-tab.is-active {
color: var(--ink);
border-bottom-color: var(--accent);
}
.pb-palette {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px;
border: 1px dashed var(--line);
border-radius: 10px;
margin-bottom: 16px;
}
.pb-canvas {
display: flex;
flex-direction: column;
gap: 14px;
}
.pb-block-card {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-flat, transparent);
}
.pb-block-card.is-dragging {
opacity: 0.5;
}
.pb-block-card.is-hidden {
opacity: 0.6;
}
.pb-block-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
.pb-drag {
cursor: grab;
color: var(--muted);
user-select: none;
}
.pb-block-body {
padding: 14px;
}
.pb-settings {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 720px;
}
.pb-danger {
border-color: #6e3b38;
color: #d98b84;
}
.pb-danger:hover:not([disabled]) {
background: rgba(110, 59, 56, 0.18);
border-color: #8a4b47;
}
/* Draft-preview banner on the public renderer. */
.page-preview-banner {
border: 1px solid var(--accent);
background: var(--blue);
color: var(--accent-bright);
border-radius: 8px;
padding: 8px 14px;
margin-bottom: 20px;
font-size: 0.85rem;
text-align: center;
}

View File

@@ -70,11 +70,10 @@ TOTP_CHALLENGE_TTL=5m
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password ADMIN_PASSWORD=change-me-admin-password
SMTP_HOST= # Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
SMTP_PORT=587 # It reuses the Google auth provider's OAuth client and stores an encrypted
SMTP_USER= # refresh token in the DB. The contact recipient is the `contact_email` site
SMTP_PASS= # setting; while email is unconfigured the contact form falls back to a mailto: link.
CONTACT_TO=UOMysticmoon@gmail.com
CLIENT_ORIGIN=http://localhost:5173 CLIENT_ORIGIN=http://localhost:5173

View File

@@ -4,16 +4,34 @@
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(32) NOT NULL UNIQUE, -- COLLATE is pinned to a case-insensitive (_ci) collation so uniqueness and
password_hash VARCHAR(72) NOT NULL, -- findByUsername lookups both fold case identically ('Foo' == 'foo'). This is
role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin', -- the atomic backstop for the username-uniqueness race (see the register /
-- change-username duplicate-key handling).
username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci UNIQUE,
-- Nullable: SSO-provisioned players have no password until they choose to set
-- one. A NULL hash means password login is impossible for that account
-- (validatePassword returns false).
password_hash VARCHAR(72) NULL,
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin',
-- Optional contact email (players). Not unique — SSO emails may repeat. Used
-- only for display + a future self-serve reset. email_verified is wired now so
-- an eventual SMTP verification flow needs no schema change.
email VARCHAR(255) NULL,
email_verified TINYINT(1) NOT NULL DEFAULT 0,
-- Account lifecycle, independent of role: staff can disable/ban a player
-- without changing their role. active = normal; disabled = admin-locked;
-- banned = moderation ban; pending = reserved for future email-verify gating.
-- Enforced in requireAuth + login (non-active is rejected).
status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active',
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA) totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
totp_enabled TINYINT(1) NOT NULL DEFAULT 0, totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
-- Any session token issued before this instant is rejected (see requireAuth). -- Any session token issued before this instant is rejected (see requireAuth).
-- Bumped on password change / "log out everywhere". NULL = no cutoff yet. -- Bumped on password change / "log out everywhere". NULL = no cutoff yet.
tokens_valid_after DATETIME NULL, tokens_valid_after DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL last_login_at DATETIME NULL,
last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS posts ( CREATE TABLE IF NOT EXISTS posts (
@@ -218,6 +236,29 @@ CREATE TABLE IF NOT EXISTS bot_config (
CONSTRAINT chk_bot_config_singleton CHECK (id = 1) CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row
-- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted
-- refresh token, never plaintext, and the client id/secret are NOT stored here —
-- they are read live from the `google` auth_providers row. The refresh token is
-- captured by the in-app "Connect Gmail" consent flow and is write-only over the
-- admin API (never returned; responses expose only hasRefreshToken).
CREATE TABLE IF NOT EXISTS email_config (
id INT PRIMARY KEY DEFAULT 1,
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2',
enabled TINYINT(1) NOT NULL DEFAULT 0,
sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo)
sender_name VARCHAR(120) NULL, -- optional From display name
refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
status VARCHAR(20) NOT NULL DEFAULT 'unconfigured',
status_detail VARCHAR(500) NULL,
last_verified_at DATETIME NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_email_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_email_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot moderation core (Phase 2). These tables are owned by the bot -- Discord bot moderation core (Phase 2). These tables are owned by the bot
-- process (its own DB pool, bot/src/db.js) — the main server never reads or -- process (its own DB pool, bot/src/db.js) — the main server never reads or
-- writes them. They live in the same physical database as everything else -- writes them. They live in the same physical database as everything else
@@ -445,6 +486,43 @@ CREATE TABLE IF NOT EXISTS mod_notes (
INDEX idx_mod_notes_user (discord_user_id, created_at) INDEX idx_mod_notes_user (discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Generic CMS pages composed from a fixed palette of blocks (the page builder).
-- `blocks` is a JSON array of block-envelope objects ({ id, type, version,
-- visible, props }); it is stored as text and parsed/validated in app code
-- against the block registry (server/src/blocks) on every save — the same
-- pattern role_menus.mapping uses, since MariaDB's JSON type is just LONGTEXT and
-- the driver hands it back as a string anyway. The seo_*/og_image/canonical_url/
-- robots and layout/nav_* columns are metadata/settings surfaced grouped in the
-- API response; several have no consumer yet but are cheap to add now and painful
-- to retrofit once real pages exist. published_at mirrors posts: stamped the first
-- time a page goes to 'published'.
CREATE TABLE IF NOT EXISTS pages (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(160) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
blocks MEDIUMTEXT NOT NULL, -- JSON array of block objects
status ENUM('draft','published') NOT NULL DEFAULT 'draft',
protected TINYINT(1) NOT NULL DEFAULT 0,
author_id INT NULL,
-- SEO / social metadata (grouped under `metadata` in the API response).
seo_title VARCHAR(200) NULL,
meta_description VARCHAR(400) NULL,
og_image VARCHAR(500) NULL,
canonical_url VARCHAR(500) NULL,
robots VARCHAR(100) NULL,
-- Presentation / navigation (grouped under `settings` in the API response).
layout ENUM('default','full_width','landing') NOT NULL DEFAULT 'default',
show_in_nav TINYINT(1) NOT NULL DEFAULT 0,
nav_group ENUM('main','footer','account','hidden') NULL,
nav_order INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
published_at DATETIME NULL,
CONSTRAINT fk_pages_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_pages_status (status),
INDEX idx_pages_nav (show_in_nav, nav_group, nav_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses -- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here. -- these columns from the CREATE TABLE above; existing installs get them here.
@@ -458,7 +536,22 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created -- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is -- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
-- an idempotent no-op, so it is safe to run on every boot. -- an idempotent no-op, so it is safe to run on every boot.
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin'; -- Player accounts: widen the enum again to include 'player' (self-service public
-- accounts). Same idempotent-MODIFY pattern.
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin';
-- Player accounts: make password_hash nullable (SSO-only players), pin the
-- username collation (case-insensitive uniqueness backstop), and add the player
-- columns to databases created before this. MODIFY is an idempotent no-op when
-- the column already matches; ADD COLUMN IF NOT EXISTS is safe to re-run.
ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(72) NULL;
ALTER TABLE users MODIFY COLUMN username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active';
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
-- Player self-registration mode: disabled | password | sso | both. Default off,
-- so the system behaves exactly as today until an admin opts in.
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;

View File

@@ -51,6 +51,13 @@ async function requireAuth(req, res, next) {
const user = await users.getById(session.userId) const user = await users.getById(session.userId)
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
// Status gate, enforced on every request (same immediacy as the cutoff
// below): a player disabled/banned by staff loses access on their very next
// request, not when their JWT eventually expires.
if (user.status && user.status !== 'active') {
return res.status(403).json({ message: 'Account disabled' })
}
// Revocation, enforced here (not in stateless token verification): // Revocation, enforced here (not in stateless token verification):
// 1. per-user cutoff — password change / "log out everywhere" bumps // 1. per-user cutoff — password change / "log out everywhere" bumps
// tokens_valid_after; any token issued before it is dead. // tokens_valid_after; any token issued before it is dead.

View File

@@ -66,6 +66,20 @@ function verifyTotpChallenge(token) {
return decoded return decoded
} }
// Short-lived, unguessable link token for previewing a (possibly unpublished)
// CMS page. Carries purpose:'page_preview' + the page id and nothing else; it is
// NOT a session (session validation rejects it) and only grants read of that one
// page's current block state. Default 1h expiry per the page-builder spec.
function signPagePreview(pageId, { expiresIn = '1h' } = {}) {
return jwt.sign({ pageId, purpose: 'page_preview' }, JWT_SECRET, { expiresIn })
}
function verifyPagePreview(token) {
const decoded = verifyToken(token)
if (!decoded || decoded.purpose !== 'page_preview') return null
return decoded
}
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m). // Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
function cookieMaxAge() { function cookieMaxAge() {
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim()) const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
@@ -122,6 +136,8 @@ module.exports = {
verifyToken, verifyToken,
signTotpChallenge, signTotpChallenge,
verifyTotpChallenge, verifyTotpChallenge,
signPagePreview,
verifyPagePreview,
cookieMaxAge, cookieMaxAge,
cookieSecure, cookieSecure,
cookieOptions, cookieOptions,

View File

@@ -0,0 +1,111 @@
// ── Username policy ────────────────────────────────────────────────────────
//
// Pure helpers shared by public registration and SSO auto-provisioning:
// - a reserved-name blocklist (staff-impersonating / system names),
// - normalization (trim; case is preserved for display, uniqueness folds case
// at the DB via the column's _ci collation), and
// - deriving a valid username from an external SSO profile.
//
// No I/O — the DB UNIQUE index is the source of truth for collisions; these
// helpers only shape/validate candidate names and pick suffixes to retry with.
// Allowed characters in a stored username: letters, digits, dot, underscore,
// dash. Length 332 (matches the register validator + the column width).
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,32}$/
const MIN_LEN = 3
const MAX_LEN = 32
// Names that must never belong to a self-registered account because they imply
// staff/system authority or are otherwise confusing. Compared case-insensitively.
const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
'root',
'system',
'staff',
'mod',
'moderator',
'owner',
'support',
'help',
'null',
'undefined',
'me',
'anonymous',
'everyone',
'here',
])
// Trim surrounding whitespace. Case is preserved (stored as entered); the DB's
// _ci collation folds case for uniqueness + lookup.
function normalizeUsername(raw) {
return typeof raw === 'string' ? raw.trim() : ''
}
function isReserved(name) {
return RESERVED_USERNAMES.has(String(name || '').trim().toLowerCase())
}
function isValidFormat(name) {
return USERNAME_RE.test(name)
}
// Validate a user-chosen username for registration. Returns { ok, message }.
function validateUsername(raw) {
const name = normalizeUsername(raw)
if (!isValidFormat(name)) {
return { ok: false, message: 'Username must be 332 characters (letters, numbers, . _ -).' }
}
if (isReserved(name)) {
return { ok: false, message: 'That username is not available.' }
}
return { ok: true, name }
}
// Reduce an arbitrary string to the allowed charset, clamped to MAX_LEN. Used as
// the base for SSO-derived usernames before uniqueness suffixing.
function sanitizeToUsername(raw) {
let s = String(raw || '')
.normalize('NFKD')
.replace(/[^A-Za-z0-9_.-]/g, '')
.replace(/^[._-]+/, '') // don't start with punctuation
.slice(0, MAX_LEN)
return s
}
// Derive a base username from a normalized SSO profile ({ name, email, subject }).
// Tries display name, then the email local-part, then a generic 'player' base.
// The result is always a valid *base* (>= MIN_LEN, sanitized) but is NOT
// guaranteed unique — the caller suffixes + retries against the UNIQUE index.
function deriveUsernameBase(profile) {
const candidates = [profile && profile.name, profile && (profile.email || '').split('@')[0]]
for (const c of candidates) {
const s = sanitizeToUsername(c)
if (s.length >= MIN_LEN && !isReserved(s)) return s
}
return 'player'
}
// Build the Nth candidate username for the dedup retry loop: attempt 0 is the
// bare base (padded if short), later attempts append an increasing numeric
// suffix, always clamped to MAX_LEN so the suffix survives truncation.
function candidateUsername(base, attempt) {
const safeBase = base.length >= MIN_LEN ? base : `${base}player`.slice(0, MAX_LEN)
if (attempt === 0) return safeBase
const suffix = String(attempt + 1) // 2, 3, 4, …
return `${safeBase.slice(0, MAX_LEN - suffix.length)}${suffix}`
}
module.exports = {
USERNAME_RE,
MIN_LEN,
MAX_LEN,
RESERVED_USERNAMES,
normalizeUsername,
isReserved,
isValidFormat,
validateUsername,
sanitizeToUsername,
deriveUsernameBase,
candidateUsername,
}

View File

@@ -0,0 +1,30 @@
// Block registry entrypoint. Requiring this module registers every server-side
// block definition (schema + cache policy) exactly once, then re-exports the
// registry API and the blocks validator. Anything that needs to validate a
// page's blocks or look up a block type should require THIS module, not
// ./registry directly, so the definitions are guaranteed to be loaded.
//
// Wave 1 block definitions are registered below, one require() per block (each
// module self-registers on load). Requiring THIS module guarantees they are all
// present before anything validates a page's blocks.
const registry = require('./registry')
const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks')
const { sanitizeBlocks } = require('./sanitizeBlocks')
// ── Wave 1 block definitions (self-register on require) ────────────────
require('./types/heading')
require('./types/richText')
require('./types/image')
require('./types/twoColumn')
require('./types/cta')
require('./types/divider')
require('./types/quote')
module.exports = {
...registry,
validateBlocks,
sanitizeBlocks,
MAX_BLOCKS,
MAX_SUBBLOCKS,
}

View File

@@ -0,0 +1,84 @@
// Small shared validators used by the Wave 1 block schemas. Each block's schema
// composes these and returns a flat array of error strings; validateBlocks
// prefixes each with the block path (so 'text is required' becomes
// 'blocks[2].props.text is required'). Phrase messages to read well after that
// prefix — start with the prop name.
/** @returns {boolean} true if v is a non-empty (after trim) string. */
function isNonEmptyString(v) {
return typeof v === 'string' && v.trim().length > 0
}
/**
* Accept a same-origin relative URL ("/uploads/x.png", "/wiki/foo") or an
* absolute http/https URL. Rejects javascript:, data:, protocol-relative
* ("//evil"), and anything else — the block renderers drop these into hrefs/src
* so this is a security boundary, not just a format check.
* @param {unknown} v
* @returns {boolean}
*/
function isSafeUrl(v) {
if (typeof v !== 'string' || v.trim() === '') return false
const s = v.trim()
if (s.startsWith('//')) return false // protocol-relative — ambiguous origin
if (s.startsWith('/')) return true // same-origin relative
try {
const u = new URL(s)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
/**
* Build an enum validator for a prop.
* @param {string} name prop name (for the message)
* @param {string[]} allowed
* @returns {(v: unknown) => string|null} error string or null
*/
function oneOf(name, allowed) {
return (v) => (allowed.includes(v) ? null : `${name} must be one of ${allowed.join(', ')}`)
}
/**
* Validate a required text prop: present, non-empty, within maxLen.
* @returns {string|null}
*/
function requiredText(name, v, maxLen) {
if (!isNonEmptyString(v)) return `${name} is required`
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
return null
}
/**
* Validate an optional text prop: if present it must be a string within maxLen.
* @returns {string|null}
*/
function optionalText(name, v, maxLen) {
if (v === undefined || v === null || v === '') return null
if (typeof v !== 'string') return `${name} must be a string`
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
return null
}
/**
* Reject any prop key not in `allowed`. Keeps a block's props tight so nothing
* unexpected is smuggled through and stored.
* @returns {string[]} error strings
*/
function onlyKeys(props, allowed) {
const errors = []
for (const key of Object.keys(props)) {
if (!allowed.includes(key)) errors.push(`${key} is not an allowed prop`)
}
return errors
}
module.exports = {
isNonEmptyString,
isSafeUrl,
oneOf,
requiredText,
optionalText,
onlyKeys,
}

View File

@@ -0,0 +1,103 @@
// Block registry (server side) — the single source of truth for what block
// types exist, how their props validate, and how long a rendered block may be
// cached. The admin builder UI, the public renderer, and this server-side
// validation are all driven from a registry entry rather than a switch statement
// scattered across files: adding a block later means adding ONE entry (here on
// the server for schema/cache, and one in client/src/blocks for the React
// renderer/editor), not editing four places.
//
// A registered definition looks like:
// {
// type: 'heading', // stable string id, unique across the registry
// version: 1, // prop-schema version; bump when props change so a
// // one-time migration can transform older blocks
// schema: (props) => [], // returns an array of error strings ([] = valid)
// sanitize: (props) => props, // optional normalizer run on save AFTER
// // validation, e.g. rich_text runs its html through
// // the shared allowlist; returns cleaned props
// cacheTTL: null, // seconds a rendered instance may be cached;
// // null = never cache (static blocks). Dynamic
// // Wave 2 blocks set this (e.g. server_status: 10).
// container: false, // true only for block types that hold sub-blocks
// containerSlots: [], // prop keys holding sub-block arrays, e.g.
// // ['left','right'] for two_column
// }
//
// This module is intentionally empty of block types — it only defines the
// pattern. Wave 1 block definitions register themselves via ./index.js.
// The only keys allowed at the top level of a stored block object. Everything
// block-specific lives inside `props`; nothing else lives at the top level.
// Ordering is the array position, not a stored field — so a reorder is just a
// reorder of the array, and `id` is never derived from position.
const RESERVED_KEYS = Object.freeze(['id', 'type', 'version', 'visible', 'props'])
const registry = new Map()
/**
* Register a block definition. Throws on a missing type or a duplicate — both
* are programmer errors surfaced at boot, not runtime input.
* @param {object} def
* @returns {object} the normalized, frozen definition
*/
function registerBlock(def) {
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
throw new Error('registerBlock: a block definition needs a string `type`')
}
if (registry.has(def.type)) {
throw new Error(`registerBlock: block type already registered: ${def.type}`)
}
if (def.schema != null && typeof def.schema !== 'function') {
throw new Error(`registerBlock: ${def.type}.schema must be a function`)
}
if (def.sanitize != null && typeof def.sanitize !== 'function') {
throw new Error(`registerBlock: ${def.type}.sanitize must be a function`)
}
const containerSlots = def.containerSlots || []
if (def.container && containerSlots.length === 0) {
throw new Error(`registerBlock: container block ${def.type} needs containerSlots`)
}
const entry = Object.freeze({
type: def.type,
version: Number.isInteger(def.version) ? def.version : 1,
schema: def.schema || null,
sanitize: def.sanitize || null,
cacheTTL: def.cacheTTL == null ? null : Number(def.cacheTTL),
container: Boolean(def.container),
containerSlots: Object.freeze([...containerSlots]),
})
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
function getBlock(type) {
return registry.get(type) || null
}
/** @returns {boolean} whether `type` is a registered block. */
function hasBlock(type) {
return registry.has(type)
}
/** @returns {object[]} all registered definitions (registration order). */
function listBlocks() {
return [...registry.values()]
}
/**
* Drop every registered block. Test-only — lets a suite register a fixture set
* and start from a known-empty registry.
*/
function _resetRegistry() {
registry.clear()
}
module.exports = {
RESERVED_KEYS,
registerBlock,
getBlock,
hasBlock,
listBlocks,
_resetRegistry,
}

View File

@@ -0,0 +1,47 @@
// Normalize + sanitize a validated blocks array before persisting. Runs AFTER
// validateBlocks (which guarantees the envelope/prop shape), so this can assume
// well-formed input and focus on: applying each block's registry `sanitize`
// normalizer (e.g. rich_text runs its html through the allowlist), stamping the
// registry `version`, defaulting `visible` to true, and recursing one level into
// container slots. Returns a new array; never mutates the input.
const { getBlock } = require('./registry')
function sanitizeBlocks(blocks) {
if (!Array.isArray(blocks)) return []
return blocks.map(sanitizeOne)
}
function sanitizeOne(block) {
const def = getBlock(block.type)
if (!def) return block // unreachable after validation, but stay defensive
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
// Recurse into container slots first (leaf sub-blocks get sanitized too).
if (def.container) {
for (const slot of def.containerSlots) {
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
}
}
// Apply the block's own normalizer last (operates on its scalar props).
if (def.sanitize) {
try {
props = def.sanitize(props)
} catch {
// Leave props as-is; validation already passed, a sanitize throw shouldn't
// block the save.
}
}
return {
id: block.id,
type: block.type,
version: Number.isInteger(block.version) ? block.version : def.version,
visible: block.visible !== false,
props,
}
}
module.exports = { sanitizeBlocks }

View File

@@ -0,0 +1,22 @@
// cta — a call-to-action button. `text` is the label, `url` the destination
// (same-origin path or http/https), `style` picks primary/secondary appearance.
const { registerBlock } = require('../registry')
const { isSafeUrl, oneOf, requiredText, onlyKeys } = require('../propHelpers')
const STYLES = ['primary', 'secondary']
const MAX_TEXT = 100
registerBlock({
type: 'cta',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['text', 'url', 'style'])
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
if (!isSafeUrl(props.url)) errors.push('url must be a same-origin path or http(s) URL')
const style = oneOf('style', STYLES)(props.style)
if (style) errors.push(style)
return errors
},
})

View File

@@ -0,0 +1,12 @@
// divider — a pure spacer / horizontal rule. Carries no props.
const { registerBlock } = require('../registry')
const { onlyKeys } = require('../propHelpers')
registerBlock({
type: 'divider',
version: 1,
cacheTTL: null,
schema(props) {
return onlyKeys(props, [])
},
})

View File

@@ -0,0 +1,21 @@
// heading — a section heading. `level` picks the tag (h1h4), `text` is plain
// text (the renderer escapes it; no HTML here — use rich_text for markup).
const { registerBlock } = require('../registry')
const { oneOf, requiredText, onlyKeys } = require('../propHelpers')
const LEVELS = ['h1', 'h2', 'h3', 'h4']
const MAX_TEXT = 200
registerBlock({
type: 'heading',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['level', 'text'])
const level = oneOf('level', LEVELS)(props.level)
if (level) errors.push(level)
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
return errors
},
})

View File

@@ -0,0 +1,27 @@
// image — a single image with optional caption. `src` must be a same-origin
// upload path or an http/https URL (isSafeUrl); `alignment` controls layout.
// Stays URL-based until the Wave 3 asset picker lands, then src swaps to an
// asset id via a small migration.
const { registerBlock } = require('../registry')
const { isSafeUrl, oneOf, optionalText, onlyKeys } = require('../propHelpers')
const ALIGNMENTS = ['left', 'center', 'right', 'full']
const MAX_ALT = 300
const MAX_CAPTION = 500
registerBlock({
type: 'image',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['src', 'alt', 'caption', 'alignment'])
if (!isSafeUrl(props.src)) errors.push('src must be a same-origin path or http(s) URL')
const alt = optionalText('alt', props.alt, MAX_ALT)
if (alt) errors.push(alt)
const caption = optionalText('caption', props.caption, MAX_CAPTION)
if (caption) errors.push(caption)
const alignment = oneOf('alignment', ALIGNMENTS)(props.alignment)
if (alignment) errors.push(alignment)
return errors
},
})

View File

@@ -0,0 +1,20 @@
// quote — a pull quote with optional attribution.
const { registerBlock } = require('../registry')
const { requiredText, optionalText, onlyKeys } = require('../propHelpers')
const MAX_TEXT = 1000
const MAX_ATTRIB = 200
registerBlock({
type: 'quote',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['text', 'attribution'])
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
const attribution = optionalText('attribution', props.attribution, MAX_ATTRIB)
if (attribution) errors.push(attribution)
return errors
},
})

View File

@@ -0,0 +1,26 @@
// rich_text — a block of HTML authored in the shared rich-text editor. Validated
// only for type/size here; the actual safety comes from `sanitize`, which runs
// the html through the same allowlist (cleanBody) used for posts/wiki bodies, so
// a direct API call can't smuggle unsafe markup past the editor.
const { registerBlock } = require('../registry')
const { onlyKeys } = require('../propHelpers')
const { cleanBody } = require('../../utils/sanitizeHtml')
const MAX_HTML = 50000
registerBlock({
type: 'rich_text',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['html'])
if (typeof props.html !== 'string') errors.push('html must be a string')
else if (props.html.length > MAX_HTML) {
errors.push(`html must be at most ${MAX_HTML} characters`)
}
return errors
},
sanitize(props) {
return { ...props, html: cleanBody(props.html) }
},
})

View File

@@ -0,0 +1,20 @@
// two_column — the only container block. Holds two ordered arrays of sub-blocks
// (`left`, `right`). The sub-block arrays are validated by validateBlocks, which
// also enforces the one-level nesting cap (a column may not contain another
// container). This schema only guards the prop shape; the slot arrays default to
// empty when absent.
const { registerBlock } = require('../registry')
const { onlyKeys } = require('../propHelpers')
registerBlock({
type: 'two_column',
version: 1,
cacheTTL: null,
container: true,
containerSlots: ['left', 'right'],
schema(props) {
// Slot array contents are validated by validateBlocks' container handling;
// here we only reject stray props.
return onlyKeys(props, ['left', 'right'])
},
})

View File

@@ -0,0 +1,119 @@
// Server-side validation for a page's `blocks` array, run on every save before
// persisting. The admin UI validates client-side too, but that can be bypassed
// by a direct API call, so this is the authoritative gate: it enforces the block
// envelope (reserved keys only), that every `type` is a registered block, that
// each block's props satisfy the registry schema, and the one-level nesting cap
// (only container blocks may hold sub-blocks, and sub-blocks may not themselves
// be containers).
//
// Returns { valid, errors } — a flat list of human-readable error strings, each
// prefixed with the path to the offending block (e.g. `blocks[2].props.text`).
// It never throws on bad input; callers turn a non-empty `errors` into a 400.
const { getBlock, RESERVED_KEYS } = require('./registry')
// Bound the payload so a single page can't carry an unreasonable block tree.
const MAX_BLOCKS = 100 // top-level blocks per page
const MAX_SUBBLOCKS = 50 // sub-blocks per container slot
const ID_RE = /^[A-Za-z0-9_-]{1,40}$/
/**
* Validate a stored blocks array against the registry.
* @param {unknown} blocks
* @returns {{ valid: boolean, errors: string[] }}
*/
function validateBlocks(blocks) {
const errors = []
if (!Array.isArray(blocks)) {
return { valid: false, errors: ['blocks must be an array'] }
}
if (blocks.length > MAX_BLOCKS) {
errors.push(`blocks may not exceed ${MAX_BLOCKS} top-level entries`)
}
const seenIds = new Set()
blocks.forEach((block, i) => {
validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false })
})
return { valid: errors.length === 0, errors }
}
/**
* Validate one block envelope in place. `nested` = true when validating a
* sub-block inside a container slot, which forbids further nesting.
*/
function validateBlock(block, path, seenIds, errors, { nested }) {
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
errors.push(`${path} must be an object`)
return
}
// Envelope: only the reserved keys, nothing smuggled at the top level.
for (const key of Object.keys(block)) {
if (!RESERVED_KEYS.includes(key)) {
errors.push(`${path}.${key} is not an allowed top-level key`)
}
}
// id — stable, unique across the whole page (top-level and nested share one
// namespace since ids are the future join point for revision history).
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
errors.push(`${path}.id must be a short id string`)
} else if (seenIds.has(block.id)) {
errors.push(`${path}.id duplicates another block id (${block.id})`)
} else {
seenIds.add(block.id)
}
// visible — optional in input, but if present must be a boolean.
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
errors.push(`${path}.visible must be a boolean`)
}
// props — always an object bag.
const props = block.props
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
errors.push(`${path}.props must be an object`)
}
// type — must resolve to a registered block.
const def = typeof block.type === 'string' ? getBlock(block.type) : null
if (!def) {
errors.push(`${path}.type is not a registered block type (${String(block.type)})`)
return // can't validate props or nesting without a definition
}
// Per-block prop schema from the registry.
if (def.schema && props && typeof props === 'object') {
let schemaErrors = []
try {
schemaErrors = def.schema(props) || []
} catch (err) {
schemaErrors = [`schema threw: ${err.message}`]
}
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
}
// Nesting: only container blocks may hold sub-blocks, capped at one level.
if (def.container) {
if (nested) {
errors.push(`${path} is a container and may not be nested inside another container`)
return
}
for (const slot of def.containerSlots) {
const sub = props ? props[slot] : undefined
if (sub === undefined) continue // an empty slot is allowed
if (!Array.isArray(sub)) {
errors.push(`${path}.props.${slot} must be an array of blocks`)
continue
}
if (sub.length > MAX_SUBBLOCKS) {
errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`)
}
sub.forEach((child, j) => {
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
})
}
}
}
module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }

View File

@@ -24,6 +24,26 @@ const loginLimiter = makeLimiter({
message: 'Too many login attempts. Please try again later.', message: 'Too many login attempts. Please try again later.',
}) })
// Public self-registration. Mirrors the login cap: a handful of legitimate
// attempts per window, a flood is abuse. The global botScore guard + honeypot
// cover the rest.
const registerLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'register',
message: 'Too many registration attempts. Please try again later.',
})
// Authenticated self-service credential changes (username / password). Tighter
// than login — a signed-in player rarely changes these, and the wrong-current-
// password path also feeds the shared login backoff (see the controller).
const accountChangeLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'account-change',
message: 'Too many changes. Please try again later.',
})
// Throttle the public contact form. // Throttle the public contact form.
const contactLimiter = makeLimiter({ const contactLimiter = makeLimiter({
windowMs: 60 * 60 * 1000, windowMs: 60 * 60 * 1000,
@@ -51,4 +71,11 @@ const ssoStartLimiter = makeLimiter({
message: 'Too many sign-in attempts. Please try again later.', message: 'Too many sign-in attempts. Please try again later.',
}) })
module.exports = { loginLimiter, contactLimiter, mobileRefreshLimiter, ssoStartLimiter } module.exports = {
loginLimiter,
registerLimiter,
accountChangeLimiter,
contactLimiter,
mobileRefreshLimiter,
ssoStartLimiter,
}

View File

@@ -0,0 +1,28 @@
const { query } = require('../../utils/db')
const COLS =
'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at'
// Singleton row (id = 1). Returns null until the admin connects Gmail for the first time.
async function get() {
const rows = await query(`SELECT ${COLS} FROM email_config WHERE id = 1 LIMIT 1`)
return rows[0] || null
}
// Upsert the singleton row. `fields` are column values already prepared by the
// model (refresh token pre-encrypted). Only the provided columns are written/updated.
async function upsert(fields) {
const cols = Object.keys(fields)
const vals = cols.map((c) => fields[c])
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO email_config (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
vals,
)
return get()
}
module.exports = { get, upsert }

View File

@@ -0,0 +1,92 @@
// Outbound email config store (Gmail OAuth2). Mirrors the botConfig model split:
// the DB layer only ever sees ciphertext, and only getWithSecret() (used by the
// mailer at send time) decrypts the refresh token. The admin-facing getSafe()
// never includes it — callers see only `hasRefreshToken`.
const db = require('./emailConfig.db')
const secretBox = require('../../utils/secretBox')
function toSafe(row) {
if (!row) {
return {
provider: 'gmail_oauth2',
enabled: false,
senderEmail: null,
senderName: null,
hasRefreshToken: false,
status: 'unconfigured',
statusDetail: null,
lastVerifiedAt: null,
}
}
return {
provider: row.provider || 'gmail_oauth2',
enabled: Boolean(row.enabled),
senderEmail: row.sender_email || null,
senderName: row.sender_name || null,
hasRefreshToken: Boolean(row.refresh_token_enc),
status: row.status || 'unconfigured',
statusDetail: row.status_detail || null,
lastVerifiedAt: row.last_verified_at || null,
}
}
async function getSafe() {
return toSafe(await db.get())
}
// Decrypted refresh token included — server-side only (building the mailer's
// OAuth2 transport). Returns null when no row exists yet.
async function getWithSecret() {
const row = await db.get()
if (!row) return null
return {
...toSafe(row),
refreshToken: row.refresh_token_enc ? secretBox.decrypt(row.refresh_token_enc) : null,
}
}
// Save admin-supplied / connect-flow config. `refreshToken` undefined or '' means
// "leave the existing token unchanged" (same convention as botConfig.save).
async function save({ senderEmail, senderName, refreshToken, enabled, status, statusDetail, updatedBy }) {
const fields = {}
if (senderEmail !== undefined) fields.sender_email = senderEmail
if (senderName !== undefined) fields.sender_name = senderName
if (refreshToken) fields.refresh_token_enc = secretBox.encrypt(refreshToken)
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
if (status !== undefined) fields.status = status
if (statusDetail !== undefined) fields.status_detail = statusDetail
if (updatedBy !== undefined) fields.updated_by = updatedBy
const row = await db.upsert(fields)
return toSafe(row)
}
// Clear the stored credential and disable sending (admin "Disconnect").
async function disconnect(updatedBy) {
const row = await db.upsert({
refresh_token_enc: null,
sender_email: null,
enabled: 0,
status: 'unconfigured',
status_detail: null,
last_verified_at: null,
updated_by: updatedBy ?? null,
})
return toSafe(row)
}
// Record the outcome of the last send / verification so the admin panel has
// something to show. `lastVerifiedAt` may arrive as a Date or ISO string.
async function recordStatus({ status, statusDetail, lastVerifiedAt } = {}) {
const fields = {}
if (status !== undefined) fields.status = status
if (statusDetail !== undefined) fields.status_detail = statusDetail ? String(statusDetail).slice(0, 500) : null
if (lastVerifiedAt !== undefined) {
fields.last_verified_at = lastVerifiedAt ? new Date(lastVerifiedAt) : null
}
if (Object.keys(fields).length === 0) return getSafe()
const row = await db.upsert(fields)
return toSafe(row)
}
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus }

View File

@@ -0,0 +1,79 @@
const { query } = require('../../utils/db')
// `blocks` is stored as a JSON string (MEDIUMTEXT) and parsed in the model.
const COLS = [
'id', 'slug', 'title', 'blocks', 'status', 'protected', 'author_id',
'seo_title', 'meta_description', 'og_image', 'canonical_url', 'robots',
'layout', 'show_in_nav', 'nav_group', 'nav_order',
'created_at', 'updated_at', 'published_at',
].join(', ')
// Admin list — every page, newest first. Excludes the (potentially large)
// blocks payload; callers that need it fetch the row by id/slug.
async function listSummaries() {
return query(
`SELECT id, slug, title, status, protected, show_in_nav, nav_group, nav_order,
updated_at, published_at
FROM pages ORDER BY updated_at DESC, id DESC`,
)
}
async function findById(id) {
const rows = await query(`SELECT ${COLS} FROM pages WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function findBySlug(slug) {
const rows = await query(`SELECT ${COLS} FROM pages WHERE slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
// Insert a fully-formed column map. `blocks` must already be a JSON string.
async function insert(page) {
const res = await query(
`INSERT INTO pages
(slug, title, blocks, status, protected, author_id,
seo_title, meta_description, og_image, canonical_url, robots,
layout, show_in_nav, nav_group, nav_order, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
page.slug,
page.title,
page.blocks,
page.status,
page.protected ? 1 : 0,
page.author_id ?? null,
page.seo_title ?? null,
page.meta_description ?? null,
page.og_image ?? null,
page.canonical_url ?? null,
page.robots ?? null,
page.layout ?? 'default',
page.show_in_nav ? 1 : 0,
page.nav_group ?? null,
page.nav_order ?? null,
page.published_at ?? null,
],
)
return res.insertId
}
// Update only the provided columns. Keys must be real column names (the model
// builds this map from a whitelist, never straight from the request body).
async function update(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE pages SET ${cols.join(', ')} WHERE id = ?`, params)
}
async function remove(id) {
return query('DELETE FROM pages WHERE id = ?', [id])
}
module.exports = { listSummaries, findById, findBySlug, insert, update, remove }

View File

@@ -0,0 +1,306 @@
// CMS pages model. Owns the rules the API surface must not bypass:
// - blocks are validated against the block registry and sanitized on every
// save (the authoritative gate — a direct API call can't skip it);
// - the DB row is mapped to/from the grouped API shape (metadata / settings);
// - slug is validated + reserved-checked at create and is immutable after;
// - `protected` can be turned ON via a normal update but only OFF via the
// dedicated unprotect path (see unprotect()), enforced here regardless of
// what the request body contains.
//
// Business/validation failures throw a PageError carrying an HTTP status + code
// so the controller can translate without knowing the rules.
const pagesDb = require('./pages.db')
const { isReservedSlug } = require('./reservedSlugs')
const { validateBlocks, sanitizeBlocks } = require('../../blocks')
const LAYOUTS = ['default', 'full_width', 'landing']
const NAV_GROUPS = ['main', 'footer', 'account', 'hidden']
const STATUSES = ['draft', 'published']
const SLUG_RE = /^[a-z0-9-]+$/
const MAX_SLUG = 160
class PageError extends Error {
constructor(status, code, message, extra) {
super(message)
this.name = 'PageError'
this.status = status
this.code = code
if (extra) Object.assign(this, extra)
}
}
// ── Serialization (row → API shape) ───────────────────────────────────
function parseBlocks(raw) {
if (raw == null || raw === '') return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function serialize(row) {
if (!row) return null
return {
id: row.id,
slug: row.slug,
title: row.title,
status: row.status,
blocks: parseBlocks(row.blocks),
metadata: {
seoTitle: row.seo_title,
metaDescription: row.meta_description,
ogImage: row.og_image,
canonicalUrl: row.canonical_url,
robots: row.robots,
},
settings: {
layout: row.layout,
showInNav: Boolean(row.show_in_nav),
navGroup: row.nav_group,
navOrder: row.nav_order,
protected: Boolean(row.protected),
},
authorId: row.author_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
publishedAt: row.published_at,
}
}
function serializeSummary(row) {
return {
id: row.id,
slug: row.slug,
title: row.title,
status: row.status,
protected: Boolean(row.protected),
showInNav: Boolean(row.show_in_nav),
navGroup: row.nav_group,
navOrder: row.nav_order,
updatedAt: row.updated_at,
publishedAt: row.published_at,
}
}
// ── Field validation / mapping ────────────────────────────────────────
function assertSlug(slug) {
if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > MAX_SLUG) {
throw new PageError(400, 'invalid_slug', 'Slug must be lowercase letters, numbers and dashes.')
}
if (isReservedSlug(slug)) {
throw new PageError(400, 'reserved_slug', `"${slug}" is a reserved slug.`)
}
}
function assertStatus(status) {
if (status !== undefined && !STATUSES.includes(status)) {
throw new PageError(400, 'invalid_status', `status must be one of ${STATUSES.join(', ')}.`)
}
}
// Validate + sanitize blocks; returns a JSON string ready to store.
function buildBlocks(blocks) {
const { valid, errors } = validateBlocks(blocks)
if (!valid) {
throw new PageError(400, 'invalid_blocks', 'One or more blocks are invalid.', { errors })
}
return JSON.stringify(sanitizeBlocks(blocks))
}
// Map the grouped `metadata` object to DB columns. Only keys present in the
// input are returned, so a PATCH touches only what it sends.
function mapMetadata(metadata) {
const cols = {}
if (!metadata || typeof metadata !== 'object') return cols
const strOrNull = (v, max, field) => {
if (v === null || v === undefined || v === '') return null
if (typeof v !== 'string' || v.length > max) {
throw new PageError(400, 'invalid_metadata', `${field} must be a string of at most ${max} characters.`)
}
return v
}
if ('seoTitle' in metadata) cols.seo_title = strOrNull(metadata.seoTitle, 200, 'seoTitle')
if ('metaDescription' in metadata) cols.meta_description = strOrNull(metadata.metaDescription, 400, 'metaDescription')
if ('ogImage' in metadata) cols.og_image = strOrNull(metadata.ogImage, 500, 'ogImage')
if ('canonicalUrl' in metadata) cols.canonical_url = strOrNull(metadata.canonicalUrl, 500, 'canonicalUrl')
if ('robots' in metadata) cols.robots = strOrNull(metadata.robots, 100, 'robots')
return cols
}
// Map the grouped `settings` object to DB columns (except `protected`, which is
// handled by the caller so the unprotect rule stays centralized).
function mapSettings(settings) {
const cols = {}
if (!settings || typeof settings !== 'object') return cols
if ('layout' in settings) {
if (!LAYOUTS.includes(settings.layout)) {
throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
}
cols.layout = settings.layout
}
if ('showInNav' in settings) {
if (typeof settings.showInNav !== 'boolean') {
throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
}
cols.show_in_nav = settings.showInNav ? 1 : 0
}
if ('navGroup' in settings) {
if (settings.navGroup !== null && !NAV_GROUPS.includes(settings.navGroup)) {
throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
}
cols.nav_group = settings.navGroup
}
if ('navOrder' in settings) {
if (settings.navOrder !== null && !Number.isInteger(settings.navOrder)) {
throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
}
cols.nav_order = settings.navOrder
}
return cols
}
// ── Reads ─────────────────────────────────────────────────────────────
async function list() {
const rows = await pagesDb.listSummaries()
return rows.map(serializeSummary)
}
async function getById(id) {
return serialize(await pagesDb.findById(id))
}
// Public read by slug. Non-admins only see published pages (returns null for a
// draft so the caller can 404 it indistinguishably from a missing page).
async function getBySlug(slug, { includeUnpublished = false } = {}) {
const row = await pagesDb.findBySlug(slug)
if (!row) return null
if (!includeUnpublished && row.status !== 'published') return null
return serialize(row)
}
// Raw row (for the controller's protected/status checks without re-serializing).
async function getRawById(id) {
return pagesDb.findById(id)
}
// ── Writes ────────────────────────────────────────────────────────────
async function create(input, authorId) {
const { slug, title, blocks = [], status = 'draft', metadata, settings } = input
assertSlug(slug)
assertStatus(status)
if (typeof title !== 'string' || title.trim() === '' || title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
const row = {
slug,
title: title.trim(),
blocks: buildBlocks(blocks),
status,
author_id: authorId,
...mapMetadata(metadata),
...mapSettings(settings),
protected: settings && settings.protected === true ? 1 : 0,
published_at: status === 'published' ? new Date() : null,
}
let id
try {
id = await pagesDb.insert(row)
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
throw new PageError(409, 'slug_taken', `A page with slug "${slug}" already exists.`)
}
throw err
}
return getById(id)
}
async function update(id, patch) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
// slug is immutable after create — reject an attempt rather than silently
// ignoring it, so the caller knows their change didn't take.
if (patch.slug !== undefined && patch.slug !== current.slug) {
throw new PageError(400, 'slug_immutable', 'A page slug cannot be changed after creation.')
}
const fields = {}
if (patch.title !== undefined) {
if (typeof patch.title !== 'string' || patch.title.trim() === '' || patch.title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
fields.title = patch.title.trim()
}
if (patch.blocks !== undefined) {
fields.blocks = buildBlocks(patch.blocks)
}
if (patch.status !== undefined) {
assertStatus(patch.status)
fields.status = patch.status
// Stamp published_at the first time a page becomes published.
if (patch.status === 'published' && !current.published_at) {
fields.published_at = new Date()
}
}
Object.assign(fields, mapMetadata(patch.metadata))
Object.assign(fields, mapSettings(patch.settings))
// Protected transitions: ON is allowed here; OFF is not (must go through the
// password-gated unprotect endpoint), regardless of the request body.
if (patch.settings && 'protected' in patch.settings) {
const want = patch.settings.protected
if (want === true) {
fields.protected = 1
} else if (want === false && current.protected) {
throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
}
// want === false while already unprotected → no-op.
}
await pagesDb.update(id, fields)
return getById(id)
}
async function remove(id) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
if (current.protected) {
throw new PageError(403, 'page_protected', 'This page is protected and cannot be deleted.')
}
await pagesDb.remove(id)
return { id }
}
// Flip protected → false. The controller performs the password step-up before
// calling this; the model just applies it.
async function unprotect(id) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
await pagesDb.update(id, { protected: 0 })
return getById(id)
}
module.exports = {
PageError,
LAYOUTS,
NAV_GROUPS,
STATUSES,
serialize,
list,
getById,
getBySlug,
getRawById,
create,
update,
remove,
unprotect,
}

View File

@@ -0,0 +1,28 @@
// Slugs a CMS page may not claim, because a top-level page lives at `/:slug` and
// must never shadow an existing named route (SPA route or API namespace). The
// catch-all page route is matched only after these, but reserving the names up
// front gives the admin a clear "that slug is reserved" error at create time
// instead of a silently unreachable page.
//
// Kept as a Set of lowercase single-segment slugs. Page slugs are validated to a
// single segment (^[a-z0-9-]+$) so we only need to guard first path segments.
const RESERVED_SLUGS = new Set([
// API / infrastructure
'api', 'internal', 'uploads', 'assets', 'static', 'public',
// Auth / account
'login', 'logout', 'register', 'account', 'auth',
// Admin app
'admin',
// Existing top-level SPA sections
'site', 'wiki', 'news', 'newsletter', 'screenshots', 'five-on-friday', 'about', 'status',
// Page-builder's own surface
'pages', 'preview',
])
/** @returns {boolean} true if `slug` collides with a reserved route name. */
function isReservedSlug(slug) {
return RESERVED_SLUGS.has(String(slug).toLowerCase())
}
module.exports = { RESERVED_SLUGS, isReservedSlug }

View File

@@ -11,6 +11,27 @@ const PUBLIC_KEYS = [
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only. 'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
] ]
// Player self-registration mode. Stored under the 'player_registration' key.
// NOTE: the raw value is never exposed publicly — getPublic() derives boolean
// availability flags from it instead (see below).
const REGISTRATION_KEY = 'player_registration'
const REGISTRATION_MODES = ['disabled', 'password', 'sso', 'both']
// Resolve the registration mode, defaulting to 'disabled' (and coercing any
// unexpected stored value back to 'disabled' so a bad row can't open sign-up).
async function getRegistrationMode() {
const value = await settingsDb.get(REGISTRATION_KEY)
return REGISTRATION_MODES.includes(value) ? value : 'disabled'
}
// Derived, public-safe availability flags for the register page.
function registrationFlags(mode) {
return {
password: mode === 'password' || mode === 'both',
sso: mode === 'sso' || mode === 'both',
}
}
async function get(key) { async function get(key) {
return settingsDb.get(key) return settingsDb.get(key)
} }
@@ -35,10 +56,26 @@ async function getAll() {
async function getPublic() { async function getPublic() {
const all = await getAll() const all = await getAll()
return PUBLIC_KEYS.reduce((acc, key) => { const out = PUBLIC_KEYS.reduce((acc, key) => {
if (all[key] !== undefined) acc[key] = all[key] if (all[key] !== undefined) acc[key] = all[key]
return acc return acc
}, {}) }, {})
// Derived registration availability (never the raw mode). Lets the register
// page show/hide the password form and SSO buttons.
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
out.registration = registrationFlags(mode)
return out
} }
module.exports = { get, set, setMany, getAll, getPublic, PUBLIC_KEYS } module.exports = {
get,
set,
setMany,
getAll,
getPublic,
PUBLIC_KEYS,
REGISTRATION_KEY,
REGISTRATION_MODES,
getRegistrationMode,
registrationFlags,
}

View File

@@ -1,11 +1,22 @@
const { query } = require('../../utils/db') const { query } = require('../../utils/db')
const PUBLIC_COLS = 'id, username, role, totp_enabled, created_at, last_login_at' const PUBLIC_COLS =
'id, username, role, status, email, email_verified, totp_enabled, created_at, last_login_at'
async function insertUser({ username, passwordHash, role = 'admin' }) { // passwordHash may be null (SSO-provisioned players who have not set one yet).
// email/status/emailVerified are optional so existing admin-create callers are
// unaffected.
async function insertUser({
username,
passwordHash = null,
role = 'admin',
email = null,
status = 'active',
emailVerified = false,
}) {
const res = await query( const res = await query(
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', 'INSERT INTO users (username, password_hash, role, email, status, email_verified) VALUES (?, ?, ?, ?, ?, ?)',
[username, passwordHash, role], [username, passwordHash, role, email, status, emailVerified ? 1 : 0],
) )
return res.insertId return res.insertId
} }
@@ -50,8 +61,8 @@ async function countAdmins() {
return Number(rows[0].c) return Number(rows[0].c)
} }
async function touchLastLogin(id) { async function touchLastLogin(id, ip = null) {
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id]) return query('UPDATE users SET last_login_at = NOW(), last_login_ip = ? WHERE id = ?', [ip, id])
} }
// Move the "tokens valid after" cutoff to now, invalidating every session token // Move the "tokens valid after" cutoff to now, invalidating every session token
@@ -61,6 +72,16 @@ async function bumpTokensValidAfter(id) {
return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id]) return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id])
} }
// Set the cutoff to an explicit instant. Used when re-issuing the caller's own
// session right after a password change: the bump above revokes everything at
// NOW(), and requireAuth's cutoff test is inclusive (createdAt <= cutoff), so a
// freshly-minted token sharing that same wall-clock second would be revoked too.
// Rewinding the cutoff a hair below the new token's issued-at lets it survive
// while still revoking every older session.
async function setTokensValidAfter(id, when) {
return query('UPDATE users SET tokens_valid_after = ? WHERE id = ?', [when, id])
}
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step // Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
// so a secret is never trusted until the user has confirmed one code. // so a secret is never trusted until the user has confirmed one code.
async function setTotpSecret(id, secret) { async function setTotpSecret(id, secret) {
@@ -86,6 +107,7 @@ module.exports = {
countAdmins, countAdmins,
touchLastLogin, touchLastLogin,
bumpTokensValidAfter, bumpTokensValidAfter,
setTokensValidAfter,
setTotpSecret, setTotpSecret,
enableTotp, enableTotp,
disableTotp, disableTotp,

View File

@@ -10,12 +10,21 @@ function sanitize(user) {
return safe return safe
} }
async function createUser({ username, password, role = 'admin' }) { // password may be omitted/null — an SSO-provisioned player has no password until
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS) // they set one (a null hash makes password login impossible, see validatePassword).
const id = await usersDb.insertUser({ username, passwordHash, role }) async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) {
const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null
const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified })
return sanitize(await usersDb.findById(id)) return sanitize(await usersDb.findById(id))
} }
// True when a DB error is the unique-index violation on username (the atomic
// backstop for the uniqueness race). Callers translate this into a 409 rather
// than doing a check-then-write.
function isDuplicateUsername(err) {
return Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062))
}
// Returns the raw row (incl. hash) — used by login only. // Returns the raw row (incl. hash) — used by login only.
async function getRawByUsername(username) { async function getRawByUsername(username) {
return usersDb.findByUsername(username) return usersDb.findByUsername(username)
@@ -52,10 +61,13 @@ async function list() {
return usersDb.listUsers() return usersDb.listUsers()
} }
async function update(id, { username, password, role }) { async function update(id, { username, password, role, email, status, emailVerified }) {
const fields = {} const fields = {}
if (username !== undefined) fields.username = username if (username !== undefined) fields.username = username
if (role !== undefined) fields.role = role if (role !== undefined) fields.role = role
if (email !== undefined) fields.email = email
if (status !== undefined) fields.status = status
if (emailVerified !== undefined) fields.email_verified = emailVerified ? 1 : 0
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS) if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
await usersDb.updateUser(id, fields) await usersDb.updateUser(id, fields)
// A password change must revoke existing sessions ("change password to log // A password change must revoke existing sessions ("change password to log
@@ -70,6 +82,12 @@ async function invalidateSessions(id) {
return usersDb.bumpTokensValidAfter(id) return usersDb.bumpTokensValidAfter(id)
} }
// Set the session cutoff to an explicit instant. Used by the self password-change
// flow to keep the caller's freshly re-issued session alive (see users.db).
async function setSessionCutoff(id, when) {
return usersDb.setTokensValidAfter(id, when)
}
async function remove(id) { async function remove(id) {
return usersDb.deleteUser(id) return usersDb.deleteUser(id)
} }
@@ -82,12 +100,13 @@ async function countAdmins() {
return usersDb.countAdmins() return usersDb.countAdmins()
} }
async function recordLogin(id) { async function recordLogin(id, ip = null) {
return usersDb.touchLastLogin(id) return usersDb.touchLastLogin(id, ip)
} }
module.exports = { module.exports = {
createUser, createUser,
isDuplicateUsername,
getRawByUsername, getRawByUsername,
getById, getById,
getRawById, getRawById,
@@ -95,6 +114,7 @@ module.exports = {
list, list,
update, update,
invalidateSessions, invalidateSessions,
setSessionCutoff,
remove, remove,
count, count,
countAdmins, countAdmins,

View File

@@ -5,18 +5,115 @@
const users = require('../../../model/users/users.model') const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model') const activity = require('../../../model/activity/activity.model')
const userIdentities = require('../../../model/userIdentities/userIdentities.model') const userIdentities = require('../../../model/userIdentities/userIdentities.model')
const sessionService = require('../../../auth/session.service')
const { setAuthCookie } = require('../../../auth/token')
const usernamePolicy = require('../../../auth/usernamePolicy')
const loginProtection = require('../../../middleware/loginProtection')
const botScore = require('../../../middleware/botScore')
const totp = require('../../../utils/totp') const totp = require('../../../utils/totp')
const log = require('../../../utils/logger')('account') const log = require('../../../utils/logger')('account')
// Current user's security status (does not expose the secret). // Current user's security status (does not expose the secret). has_password lets
// the player portal tell an SSO-only account (must *set* a password, no current
// one required) apart from one that already has a usable password. req.user is the
// sanitized row (password_hash stripped), so read the raw row for that one flag.
async function getAccount(req, res) { async function getAccount(req, res) {
try {
const raw = await users.getRawById(req.user.id)
return res.json({ return res.json({
id: req.user.id, id: req.user.id,
username: req.user.username, username: req.user.username,
role: req.user.role, role: req.user.role,
email: req.user.email || null,
status: req.user.status || 'active',
totp_enabled: Boolean(req.user.totp_enabled), totp_enabled: Boolean(req.user.totp_enabled),
has_password: Boolean(raw && raw.password_hash),
}) })
} catch (err) {
log.error('getAccount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Re-mint this caller's session and refresh their cookie so a self-service change
// (username/password) doesn't log them out. Returns the new Session object.
function reissueSession(req, res, user) {
const { token: sessionToken, session } = sessionService.createSession(user, req.authMethod || 'local')
setAuthCookie(req, res, sessionToken)
return session
}
// PATCH /account/username — change the caller's own username. The DB UNIQUE index
// is the source of truth for collisions (case-insensitive via the column's _ci
// collation): attempt the write and translate a duplicate-key error into 409.
async function changeUsername(req, res) {
const check = usernamePolicy.validateUsername(req.body.username)
if (!check.ok) return res.status(400).json({ message: check.message })
try {
if (check.name === req.user.username) {
return res.status(400).json({ message: 'That is already your username.' })
}
let updated
try {
updated = await users.update(req.user.id, { username: check.name })
} catch (err) {
if (users.isDuplicateUsername(err)) {
return res.status(409).json({ message: 'That username is already taken.' })
}
throw err
}
// The JWT embeds username; authz always uses the fresh DB row, but re-issue
// the cookie so nothing downstream renders a stale name. No global revocation
// — a username isn't a secret.
reissueSession(req, res, updated)
await activity.log({ req, action: 'account.username.change', detail: { username: updated.username } })
log.info('account username changed', { id: req.user.id, username: updated.username })
return res.json({ username: updated.username })
} catch (err) {
log.error('changeUsername', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PATCH /account/password — change (or set) the caller's own password.
// • Account already has a password: require currentPassword and verify it.
// • SSO-provisioned account with a null hash: allow setting an initial password
// with no current password required.
// users.update rotates the hash and revokes existing sessions; we then re-issue
// this caller's session so their own change doesn't log them out.
async function changePassword(req, res) {
try {
const raw = await users.getRawById(req.user.id)
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
if (raw.password_hash) {
const ok = await users.validatePassword(raw, req.body.currentPassword || '')
if (!ok) {
// A wrong current password is credential-guessing — trip the same
// backoff + bot scoring as a failed login.
loginProtection.recordFailure(req.ip)
botScore.recordLoginFailure(req.ip)
log.warn('changePassword wrong current password', { id: req.user.id, ip: req.ip })
return res.status(400).json({ message: 'Your current password is incorrect.' })
}
}
// Rotate the hash + revoke every existing session (users.update bumps the cutoff).
const updated = await users.update(req.user.id, { password: req.body.newPassword })
// Re-issue this caller's session, then rewind the cutoff just below the new
// token's issued-at so the inclusive cutoff test doesn't catch it (see users.db).
const session = reissueSession(req, res, updated)
if (session && session.createdAt) {
await users.setSessionCutoff(req.user.id, new Date(session.createdAt - 1000))
}
await activity.log({ req, action: 'account.password.change' })
log.info('account password changed', { id: req.user.id })
return res.json({ ok: true })
} catch (err) {
log.error('changePassword', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
} }
// Step 1: generate a fresh secret (stored but not yet enabled) and return the // Step 1: generate a fresh secret (stored but not yet enabled) and return the
@@ -110,4 +207,13 @@ async function unlinkIdentity(req, res) {
} }
} }
module.exports = { getAccount, totpSetup, totpEnable, totpDisable, listIdentities, unlinkIdentity } module.exports = {
getAccount,
changeUsername,
changePassword,
totpSetup,
totpEnable,
totpDisable,
listIdentities,
unlinkIdentity,
}

View File

@@ -454,6 +454,13 @@ async function updateSettings(req, res) {
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return res.status(400).json({ message: 'Expected an object of key/value settings' }) return res.status(400).json({ message: 'Expected an object of key/value settings' })
} }
// Enum-constrained keys are validated here (the store itself is schemaless).
if (
settings.REGISTRATION_KEY in updates &&
!settings.REGISTRATION_MODES.includes(updates[settings.REGISTRATION_KEY])
) {
return res.status(400).json({ message: 'Invalid player_registration value' })
}
try { try {
await settings.setMany(updates, req.user.id) await settings.setMany(updates, req.user.id)
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } }) await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
@@ -493,8 +500,14 @@ async function createUser(req, res) {
username: req.body.username, username: req.body.username,
password: req.body.password, password: req.body.password,
role: req.body.role || 'admin', role: req.body.role || 'admin',
email: req.body.email || null,
status: req.body.status || 'active',
})
await activity.log({
req,
action: 'user.create',
detail: { id: user.id, username: user.username, role: user.role },
}) })
await activity.log({ req, action: 'user.create', detail: { id: user.id, username: user.username } })
return res.status(201).json(user) return res.status(201).json(user)
} catch (err) { } catch (err) {
log.error('createUser', err) log.error('createUser', err)
@@ -525,8 +538,26 @@ async function updateUser(req, res) {
username: req.body.username, username: req.body.username,
password: req.body.password, password: req.body.password,
role: req.body.role, role: req.body.role,
email: req.body.email,
status: req.body.status,
}) })
await activity.log({ req, action: 'user.update', detail: { id } }) await activity.log({ req, action: 'user.update', detail: { id } })
// Distinct audit trail for the security-sensitive fields (role & status),
// so a promotion/ban is greppable beyond the generic user.update entry.
if (req.body.role && req.body.role !== target.role) {
await activity.log({
req,
action: 'admin.user.role_change',
detail: { id, from: target.role, to: req.body.role },
})
}
if (req.body.status && req.body.status !== target.status) {
await activity.log({
req,
action: 'admin.user.status_change',
detail: { id, from: target.status, to: req.body.status },
})
}
return res.json(user) return res.json(user)
} catch (err) { } catch (err) {
log.error('updateUser', err) log.error('updateUser', err)

View File

@@ -10,15 +10,22 @@ const account = require('./account.controller')
const botActivity = require('./botActivity.controller') const botActivity = require('./botActivity.controller')
const authProviders = require('./authProviders.controller') const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller') const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
const moderation = require('./moderation.controller') const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth') const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex') const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
const adminRouter = express.Router() const adminRouter = express.Router()
// Every admin route requires auth and is kept out of search indexes. // Every admin route requires auth, a STAFF role, and is kept out of search
adminRouter.use(noindex, isLoggedIn) // indexes. The staff gate matters now that `player` is a logged-in-but-untrusted
// role: without it, the editor-tier routes below (dashboard, posts, wiki,
// uploads) that are only guarded by isLoggedIn would be reachable by players.
// Players get 403 here and use the self-scoped /player group instead.
const staffOnly = requireRole('admin', 'editor', 'moderator')
adminRouter.use(noindex, isLoggedIn, staffOnly)
// Admin-only gate. Editors may manage content (posts/wiki), but user // Admin-only gate. Editors may manage content (posts/wiki), but user
// management, site mode, and settings are restricted to the admin role. // management, site mode, and settings are restricted to the admin role.
@@ -469,6 +476,99 @@ adminRouter.delete(
ctrl.deleteWiki, ctrl.deleteWiki,
) )
// ── CMS Pages (block-based page builder) ──────────────────────────────
adminRouter.get(
'/pages',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'List all CMS pages (summaries)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
pagesCtrl.listPages,
)
adminRouter.post(
'/pages',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Create a CMS page'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */
/* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('slug').isString().trim().notEmpty(),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
validate,
pagesCtrl.createPage,
)
adminRouter.get(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Get a CMS page by id (full, incl. blocks)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.getPage,
)
adminRouter.patch(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)'
// #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.updatePage,
)
adminRouter.delete(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Delete a CMS page (blocked if protected)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
/* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.deletePage,
)
adminRouter.post(
'/pages/:id/unprotect',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Disable page protection (password step-up re-auth)'
// #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */
/* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
body('password').isString().notEmpty(),
validate,
pagesCtrl.unprotectPage,
)
adminRouter.post(
'/pages/:id/preview',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Mint a 1h draft-preview link for a page'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.createPreview,
)
// ── Settings ────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────
adminRouter.get( adminRouter.get(
'/settings', '/settings',
@@ -570,6 +670,84 @@ adminRouter.put(
discordBot.saveConfig, discordBot.saveConfig,
) )
// ── Email delivery (Gmail OAuth2, admin only) ─────────────────────────
// Modern replacement for env SMTP: the refresh token is captured by the connect
// flow and is write-only over this API (stored encrypted, never returned).
adminRouter.get(
'/email/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Get email delivery config + status (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Config (refresh token stripped) + status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.getConfig,
)
adminRouter.put(
'/email/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Update email delivery config (admin only)'
// #swagger.description = 'Set the From display name and enabled toggle. Enabling requires a connected Gmail account.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { senderName: { type: "string" }, enabled: { type: "boolean" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Cannot enable before connecting a mailbox', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('senderName').optional({ values: 'null' }).isString().trim().isLength({ max: 120 }),
body('enabled').optional().isBoolean(),
validate,
emailConfig.saveConfig,
)
adminRouter.get(
'/email/connect/start',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Begin the Gmail OAuth2 connect flow (admin only)'
// #swagger.description = 'Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Authorization URL', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Google OAuth client not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.connectStart,
)
adminRouter.get(
'/email/connect/callback',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'OAuth2 callback — stores the refresh token, redirects to Settings'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[302] = { description: 'Redirect back to /admin/settings' } */
adminOnly,
emailConfig.connectCallback,
)
adminRouter.post(
'/email/test',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Send a test email (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", format: "email" } } } } } } */
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
/* #swagger.responses[502] = { description: 'Send failed / not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('to').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
validate,
emailConfig.testSend,
)
adminRouter.post(
'/email/disconnect',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Disconnect Gmail and disable email (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Disconnected config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.disconnect,
)
// ── Authentication providers / SSO (admin only) ─────────────────────── // ── Authentication providers / SSO (admin only) ───────────────────────
adminRouter.get( adminRouter.get(
'/auth/providers', '/auth/providers',
@@ -763,7 +941,9 @@ adminRouter.post(
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('username').isString().trim().isLength({ min: 3, max: 32 }), body('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }), body('password').isString().isLength({ min: 8, max: 64 }),
body('role').optional().isIn(['admin', 'editor', 'moderator']), body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
validate, validate,
ctrl.createUser, ctrl.createUser,
) )
@@ -783,7 +963,9 @@ adminRouter.put(
param('id').isInt(), param('id').isInt(),
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }), body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
body('password').optional().isString().isLength({ min: 8, max: 64 }), body('password').optional().isString().isLength({ min: 8, max: 64 }),
body('role').optional().isIn(['admin', 'editor', 'moderator']), body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }),
validate, validate,
ctrl.updateUser, ctrl.updateUser,
) )

View File

@@ -0,0 +1,211 @@
// ── Admin: outbound email configuration (Gmail OAuth2) ─────────────────────
//
// Modern replacement for env-var SMTP. Sending goes through Gmail over OAuth2;
// the admin connects the mailbox with an in-app consent flow that captures a
// refresh token. We reuse the existing `google` SSO OAuth client (its id/secret)
// rather than a second app — so the only per-mailbox secret is the refresh token,
// stored AES-GCM-encrypted and write-only over this API (never returned).
//
// The connect flow mirrors sso.controller.js: a signed httpOnly tx cookie carries
// the CSRF nonce + PKCE verifier across the redirect to Google and back. It differs
// only in scope (https://mail.google.com/ for SMTP XOAUTH2) and access_type=offline
// + prompt=consent, which guarantee a refresh token even on reconnect.
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
const authProviders = require('../../../model/authProviders/authProviders.model')
const activity = require('../../../model/activity/activity.model')
const mailer = require('../../../utils/mailer')
const GoogleProvider = require('../../../auth/providers/google.provider')
const ssoState = require('../../../auth/ssoState')
const token = require('../../../auth/token')
const log = require('../../../utils/logger')('admin')
// Gmail scope grants SMTP (XOAUTH2) access; openid+email let us read back which
// address was connected. The narrower gmail.send scope only works via the Gmail
// API, not SMTP, so we need the full-access scope here.
const EMAIL_SCOPE = 'https://mail.google.com/ openid email'
const TX_COOKIE = 'email_oauth_tx'
// Public base URL for the OAuth redirect_uri — same fallback pattern as
// sso.controller.js. Must be identical between start and callback.
function appBaseUrl(req) {
const configured = process.env.APP_BASE_URL
if (configured) return configured.replace(/\/+$/, '')
const derived = `${req.protocol}://${req.get('host')}`
log.warn('APP_BASE_URL not set — deriving email redirect_uri from the request', { derived })
return derived
}
function redirectUri(req) {
return `${appBaseUrl(req)}/api/v1/admin/email/connect/callback`
}
function txCookieOptions(req) {
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
}
// Front-end redirect targets after the callback resolves.
const CONNECTED_URL = '/admin/settings?email_connected=1'
const errorUrl = (code) => `/admin/settings?email_error=${code}`
// Load the Google OAuth client (id + decrypted secret) reused for email. Returns
// null when the google provider hasn't been configured with credentials yet.
async function googleClient() {
const row = await authProviders.getWithSecret('google')
if (!row || !row.client_id || !row.client_secret) return null
return { clientId: row.client_id, clientSecret: row.client_secret }
}
// GET /admin/email/config
async function getConfig(req, res) {
try {
const config = await emailConfig.getSafe()
// Surface whether the Google client email can borrow is configured, so the
// UI can explain why Connect is unavailable.
config.googleConfigured = Boolean(await googleClient())
return res.json(config)
} catch (err) {
log.error('emailConfig.getConfig', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PUT /admin/email/config — sender name + enabled toggle. Enabling requires a
// connected mailbox (a stored refresh token).
async function saveConfig(req, res) {
const { senderName, enabled } = req.body
try {
const current = await emailConfig.getSafe()
if (enabled && !current.hasRefreshToken) {
return res.status(400).json({ message: 'Connect a Gmail account before enabling email.' })
}
const saved = await emailConfig.save({
senderName: senderName !== undefined ? senderName || null : undefined,
enabled,
updatedBy: req.user.id,
})
saved.googleConfigured = Boolean(await googleClient())
await activity.log({ req, action: 'email.config.update', detail: { enabled: saved.enabled } })
log.info('email config updated', { by: req.user.username, enabled: saved.enabled })
return res.json(saved)
} catch (err) {
log.error('emailConfig.saveConfig', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/email/connect/start — returns { url } for the browser to navigate to.
async function connectStart(req, res) {
try {
const client = await googleClient()
if (!client) {
return res.status(400).json({
message: 'Configure the Google authentication provider (client id + secret) before connecting email.',
})
}
const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret })
const tx = ssoState.createTx({ flow: 'email' })
res.cookie(TX_COOKIE, tx.txToken, txCookieOptions(req))
const params = new URLSearchParams({
client_id: client.clientId,
redirect_uri: redirectUri(req),
response_type: 'code',
scope: EMAIL_SCOPE,
access_type: 'offline',
prompt: 'consent',
include_granted_scopes: 'true',
state: tx.nonce,
code_challenge: tx.codeChallenge,
code_challenge_method: 'S256',
})
const url = `${provider.authEndpoint()}?${params.toString()}`
return res.json({ url })
} catch (err) {
log.error('emailConfig.connectStart', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/email/connect/callback — exchange the code, capture the refresh
// token + connected address, store encrypted, and redirect back to Settings.
async function connectCallback(req, res) {
const txToken = req.cookies && req.cookies[TX_COOKIE]
const { code, state, error: oauthError } = req.query
res.clearCookie(TX_COOKIE, token.cookieOptions(req)) // single-use
if (oauthError) {
log.warn('email connect: provider returned error', { error: String(oauthError).slice(0, 60) })
return res.redirect(errorUrl('denied'))
}
const tx = ssoState.verifyTx(txToken, state)
if (!tx || tx.flow !== 'email' || !code) {
log.warn('email connect: bad state')
return res.redirect(errorUrl('bad_state'))
}
try {
const client = await googleClient()
if (!client) return res.redirect(errorUrl('no_client'))
const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret })
const tokenSet = await provider.exchangeCode({
code,
redirectUri: redirectUri(req),
codeVerifier: tx.verifier,
})
if (!tokenSet.refresh_token) {
// Google only returns a refresh token when it hasn't already granted one
// for this client+scope. prompt=consent should force it; if it's still
// missing the admin can revoke the app's access and retry.
log.warn('email connect: no refresh_token returned')
return res.redirect(errorUrl('no_refresh_token'))
}
const profile = await provider.getUserProfile(tokenSet.access_token)
const senderEmail = profile.email || null
if (!senderEmail) return res.redirect(errorUrl('no_email'))
await emailConfig.save({
senderEmail,
refreshToken: tokenSet.refresh_token,
enabled: true,
status: 'connected',
statusDetail: 'Connected',
updatedBy: req.user.id,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Connected', lastVerifiedAt: new Date() })
await activity.log({ req, action: 'email.connect', detail: { senderEmail } })
log.info('email connected', { senderEmail, by: req.user.username })
return res.redirect(CONNECTED_URL)
} catch (err) {
log.error('emailConfig.connectCallback', err)
return res.redirect(errorUrl('error'))
}
}
// POST /admin/email/test — send a test message (to the given address, or the
// contact recipient by default).
async function testSend(req, res) {
try {
const result = await mailer.sendTest(req.body.to)
await activity.log({ req, action: 'email.test', detail: { to: result.to } })
return res.json(result)
} catch (err) {
log.warn('email test send failed', { message: err.message })
return res.status(502).json({ message: err.message || 'Could not send the test email.' })
}
}
// POST /admin/email/disconnect — clear the stored credential and disable sending.
async function disconnect(req, res) {
try {
const config = await emailConfig.disconnect(req.user.id)
config.googleConfigured = Boolean(await googleClient())
await activity.log({ req, action: 'email.disconnect' })
log.info('email disconnected', { by: req.user.username })
return res.json(config)
} catch (err) {
log.error('emailConfig.disconnect', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getConfig, saveConfig, connectStart, connectCallback, testSend, disconnect }

View File

@@ -0,0 +1,131 @@
// Admin CMS pages controller. Thin HTTP layer over pages.model — it translates
// the model's PageError (status + code) into responses and records audit-log
// entries for the lifecycle events the spec calls out (create / publish /
// unpublish / delete, protect on, and the password-gated unprotect).
const pages = require('../../../model/pages/pages.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const token = require('../../../auth/token')
const logger = require('../../../utils/logger')('pages')
// Map a thrown error to a response. Known PageErrors carry a status + code (and
// sometimes a block-error list); anything else is an unexpected 500.
function fail(res, err) {
if (err && err.name === 'PageError') {
const body = { message: err.message, code: err.code }
if (err.errors) body.details = err.errors
return res.status(err.status).json(body)
}
logger.error('unexpected pages error', { error: err.message })
return res.status(500).json({ message: 'Internal error' })
}
async function listPages(req, res) {
return res.json(await pages.list())
}
async function getPage(req, res) {
const page = await pages.getById(Number(req.params.id))
if (!page) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
return res.json(page)
}
async function createPage(req, res) {
try {
const page = await pages.create(req.body, req.user.id)
await activity.log({ req, action: 'page.create', detail: { id: page.id, slug: page.slug } })
if (page.status === 'published') {
await activity.log({ req, action: 'page.publish', detail: { id: page.id, slug: page.slug } })
}
return res.status(201).json(page)
} catch (err) {
return fail(res, err)
}
}
async function updatePage(req, res) {
try {
const id = Number(req.params.id)
const before = await pages.getRawById(id)
if (!before) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
const page = await pages.update(id, req.body)
await activity.log({ req, action: 'page.update', detail: { id, slug: page.slug } })
// Emit dedicated audit events for the transitions the spec singles out.
if (before.status !== page.status) {
const action = page.status === 'published' ? 'page.publish' : 'page.unpublish'
await activity.log({ req, action, detail: { id, slug: page.slug } })
}
if (!before.protected && page.settings.protected) {
await activity.log({ req, action: 'page.protect', detail: { id, slug: page.slug } })
}
return res.json(page)
} catch (err) {
return fail(res, err)
}
}
async function deletePage(req, res) {
try {
const id = Number(req.params.id)
const result = await pages.remove(id)
await activity.log({ req, action: 'page.delete', detail: { id } })
return res.json(result)
} catch (err) {
return fail(res, err)
}
}
// Step-up auth: verify the CURRENT admin's password against their own hash
// (independent of JWT validity) before flipping protected → false. On failure:
// no mutation, standard 401, and the entered password is never logged anywhere.
async function unprotectPage(req, res) {
try {
const id = Number(req.params.id)
const password = req.body?.password
if (typeof password !== 'string' || password === '') {
return res.status(400).json({ message: 'Password is required', code: 'password_required' })
}
const user = await users.getRawById(req.user.id)
const ok = await users.validatePassword(user, password)
if (!ok) {
logger.warn('failed page unprotect (bad password)', { pageId: id, userId: req.user.id })
return res.status(401).json({ message: 'Password is incorrect', code: 'bad_password' })
}
const page = await pages.unprotect(id)
await activity.log({ req, action: 'page.unprotect', detail: { id, slug: page.slug } })
return res.json(page)
} catch (err) {
return fail(res, err)
}
}
// Mint a 1h preview token for the page's current (possibly unpublished) state.
// Returns the token plus the ready-to-use public preview path.
async function createPreview(req, res) {
try {
const id = Number(req.params.id)
const page = await pages.getById(id)
if (!page) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
const t = token.signPagePreview(id)
return res.json({
token: t,
expiresInSeconds: 3600,
path: `/api/v1/public/pages/${id}/preview/${t}`,
})
} catch (err) {
return fail(res, err)
}
}
module.exports = {
listPages,
getPage,
createPage,
updatePage,
deletePage,
unprotectPage,
createPreview,
}

View File

@@ -1,10 +1,12 @@
const users = require('../../../model/users/users.model') const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model') const activity = require('../../../model/activity/activity.model')
const settings = require('../../../model/settings/settings.model')
const { setAuthCookie, clearAuthCookie } = require('../../../auth/token') const { setAuthCookie, clearAuthCookie } = require('../../../auth/token')
const sessionService = require('../../../auth/session.service') const sessionService = require('../../../auth/session.service')
const totp = require('../../../utils/totp') const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore') const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection') const loginProtection = require('../../../middleware/loginProtection')
const usernamePolicy = require('../../../auth/usernamePolicy')
const log = require('../../../utils/logger')('auth') const log = require('../../../utils/logger')('auth')
@@ -27,7 +29,7 @@ function needsTotp(user) {
// the second factor) — carried in the session token for downstream visibility. // the second factor) — carried in the session token for downstream visibility.
async function issueSession(req, res, user, authMethod = 'local') { async function issueSession(req, res, user, authMethod = 'local') {
loginProtection.recordSuccess(req.ip) loginProtection.recordSuccess(req.ip)
await users.recordLogin(user.id) await users.recordLogin(user.id, req.ip)
const { token } = sessionService.createSession(user, authMethod) const { token } = sessionService.createSession(user, authMethod)
setAuthCookie(req, res, token) setAuthCookie(req, res, token)
await activity.log({ req, userId: user.id, action: 'auth.login' }) await activity.log({ req, userId: user.id, action: 'auth.login' })
@@ -57,6 +59,14 @@ async function login(req, res) {
return res.status(401).json(GENERIC_FAIL) return res.status(401).json(GENERIC_FAIL)
} }
// Correct credentials, but the account is disabled/banned (or pending): do
// not issue a session or a TOTP challenge. A distinct, clear message here is
// fine — the caller already proved the password, so this leaks nothing.
if (user.status && user.status !== 'active') {
log.warn('login refused: inactive account', { username, status: user.status, ip: req.ip })
return res.status(403).json({ message: 'This account is not active. Contact an administrator.' })
}
// Password is correct. If this user has TOTP on, do NOT issue a session yet — // Password is correct. If this user has TOTP on, do NOT issue a session yet —
// hand back a short-lived, signed "password verified" challenge and require // hand back a short-lived, signed "password verified" challenge and require
// the code. If TOTP is off, log them straight in. // the code. If TOTP is off, log them straight in.
@@ -73,6 +83,57 @@ async function login(req, res) {
} }
} }
// Public self-registration for a `player` account. Gated by the
// `player_registration` setting (must allow the password path) and hardened the
// same way as login: honeypot + registerLimiter + the global botScore guard.
// On success the new player is auto-logged-in (session cookie set).
async function register(req, res) {
// Honeypot: identical treatment to login — a filled hidden field is a bot.
if (req.body[HONEYPOT_FIELD]) {
botScore.recordHoneypot(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('honeypot register hit', { ip: req.ip })
return res.status(400).json({ message: 'Registration failed.' })
}
try {
const mode = await settings.getRegistrationMode()
// Password self-registration is only open when the mode includes it.
if (mode !== 'password' && mode !== 'both') {
return res.status(403).json({ message: 'Registration is not open.' })
}
const check = usernamePolicy.validateUsername(req.body.username)
if (!check.ok) return res.status(400).json({ message: check.message })
const email = req.body.email ? String(req.body.email).trim() : null
let user
try {
user = await users.createUser({
username: check.name,
password: req.body.password,
email,
role: 'player',
})
} catch (err) {
// The UNIQUE index is the source of truth for the uniqueness race — a
// concurrent duplicate loses here and gets a clean 409.
if (users.isDuplicateUsername(err)) {
return res.status(409).json({ message: 'That username is already taken.' })
}
throw err
}
await activity.log({ req, userId: user.id, action: 'auth.register', detail: { username: user.username } })
log.info('player registered', { username: user.username, id: user.id, ip: req.ip })
// New password accounts never have TOTP yet — log straight in.
return issueSession(req, res, user, 'local')
} catch (err) {
log.error('register error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Second step for TOTP users: verify the challenge token + code, then issue the // Second step for TOTP users: verify the challenge token + code, then issue the
// session. A wrong code counts as a failed attempt (backoff + bot score). // session. A wrong code counts as a failed attempt (backoff + bot score).
async function loginTotp(req, res) { async function loginTotp(req, res) {
@@ -127,4 +188,4 @@ async function me(req, res) {
} }
} }
module.exports = { login, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD } module.exports = { login, register, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }

View File

@@ -1,10 +1,10 @@
const express = require('express') const express = require('express')
const { body } = require('express-validator') const { body } = require('express-validator')
const { login, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller') const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
const { isLoggedIn } = require('../../../utils/auth') const { isLoggedIn } = require('../../../utils/auth')
const { attachSession } = require('../../../auth/session.middleware') const { attachSession } = require('../../../auth/session.middleware')
const { loginLimiter } = require('../../../middleware/rateLimit') const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection') const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
const mobileRouter = require('./mobile.routes') const mobileRouter = require('./mobile.routes')
@@ -45,6 +45,30 @@ authRouter.post(
login, login,
) )
// Public self-registration (player accounts). Gated in the controller by the
// player_registration setting; here it reuses the login backoff/limiter stack
// plus its own per-IP cap, and accepts the honeypot field.
authRouter.post(
'/register',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Register a player account'
// #swagger.description = 'Creates a self-service player account and logs it in (sets the session cookie). Available only when an admin has enabled password registration (player_registration = password|both); otherwise returns 403. Rate limited and behind bot/backoff guards; a hidden honeypot field must stay empty.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RegisterRequest" } } } } */
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Registration is not open', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
registerLimiter,
body('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }),
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
body(HONEYPOT_FIELD).optional(),
validate,
register,
)
// Second factor: same throttling, since it's a code-guessing surface too. // Second factor: same throttling, since it's a code-guessing surface too.
authRouter.post( authRouter.post(
'/login/totp', '/login/totp',

View File

@@ -15,11 +15,13 @@ const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model') const activity = require('../../../model/activity/activity.model')
const authProviders = require('../../../model/authProviders/authProviders.model') const authProviders = require('../../../model/authProviders/authProviders.model')
const userIdentities = require('../../../model/userIdentities/userIdentities.model') const userIdentities = require('../../../model/userIdentities/userIdentities.model')
const settings = require('../../../model/settings/settings.model')
const registry = require('../../../auth/providers/registry') const registry = require('../../../auth/providers/registry')
const sessionService = require('../../../auth/session.service') const sessionService = require('../../../auth/session.service')
const ssoState = require('../../../auth/ssoState') const ssoState = require('../../../auth/ssoState')
const token = require('../../../auth/token') const token = require('../../../auth/token')
const totp = require('../../../utils/totp') const totp = require('../../../utils/totp')
const usernamePolicy = require('../../../auth/usernamePolicy')
const botScore = require('../../../middleware/botScore') const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection') const loginProtection = require('../../../middleware/loginProtection')
const { needsTotp } = require('./auth.controller') const { needsTotp } = require('./auth.controller')
@@ -27,15 +29,32 @@ const { needsTotp } = require('./auth.controller')
const log = require('../../../utils/logger')('sso') const log = require('../../../utils/logger')('sso')
const PROVIDER_ID_RE = /^[a-z0-9-]+$/ const PROVIDER_ID_RE = /^[a-z0-9-]+$/
// How many username suffixes to try before giving up on auto-provision.
const PROVISION_MAX_TRIES = 25
// Which front-end area a flow belongs to, derived from its returnTo. Players
// drive SSO from /account*, staff from /admin*; defaults to admin. This is what
// makes error/TOTP/success redirects land the caller back in their own portal.
function portalFor(returnTo) {
return typeof returnTo === 'string' && /^\/account(?:[/?]|$)/.test(returnTo) ? 'account' : 'admin'
}
const loginPath = (portal) => (portal === 'account' ? '/account/login' : '/admin/login')
const accountPath = (portal) => (portal === 'account' ? '/account' : '/admin/account')
const homePath = (portal) => (portal === 'account' ? '/account' : '/admin')
// Redirect targets (front-end routes). Errors surface as a query param the login // Redirect targets (front-end routes). Errors surface as a query param the login
// / account pages can render. // / account pages can render. Portal-aware so a player flow stays in /account*.
const loginError = (code) => `/admin/login?sso_error=${code}` const loginError = (code, portal = 'admin') => `${loginPath(portal)}?sso_error=${code}`
const accountError = (code) => `/admin/account?link_error=${code}` const accountError = (code, portal = 'admin') => `${accountPath(portal)}?link_error=${code}`
// Only allow returning to an internal /admin path (prevents open redirect). // Only allow returning to an internal /admin or /account path (prevents open
// redirect). Both areas are first-party SPA routes.
function sanitizeReturn(returnTo) { function sanitizeReturn(returnTo) {
if (typeof returnTo === 'string' && /^\/admin(?:[/?]|$)/.test(returnTo) && !returnTo.startsWith('//')) { if (
typeof returnTo === 'string' &&
/^\/(admin|account)(?:[/?]|$)/.test(returnTo) &&
!returnTo.startsWith('//')
) {
return returnTo return returnTo
} }
return null return null
@@ -81,20 +100,24 @@ async function listProviders(req, res) {
// requireAuth has already run so req.user is the account to attach the identity to. // requireAuth has already run so req.user is the account to attach the identity to.
async function beginFlow(req, res, mode) { async function beginFlow(req, res, mode) {
const providerId = req.params.provider const providerId = req.params.provider
const failUrl = mode === 'link' ? accountError('error') : loginError('error') const returnTo = sanitizeReturn(req.query.returnTo)
const portal = portalFor(returnTo)
const failUrl = mode === 'link' ? accountError('error', portal) : loginError('error', portal)
try { try {
if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl) if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl)
const row = await authProviders.getWithSecret(providerId) const row = await authProviders.getWithSecret(providerId)
if (!row || !row.enabled || !registry.validateConfig(row).valid) { if (!row || !row.enabled || !registry.validateConfig(row).valid) {
log.warn('sso start: provider unavailable', { provider: providerId, mode }) log.warn('sso start: provider unavailable', { provider: providerId, mode })
return res.redirect(mode === 'link' ? accountError('unavailable') : loginError('unavailable')) return res.redirect(
mode === 'link' ? accountError('unavailable', portal) : loginError('unavailable', portal),
)
} }
const provider = registry.instantiate(row) const provider = registry.instantiate(row)
const tx = ssoState.createTx({ const tx = ssoState.createTx({
provider: providerId, provider: providerId,
mode, mode,
linkUserId: mode === 'link' ? req.user.id : undefined, linkUserId: mode === 'link' ? req.user.id : undefined,
returnTo: sanitizeReturn(req.query.returnTo) || undefined, returnTo: returnTo || undefined,
}) })
res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req)) res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req))
const url = provider.getAuthorizationUrl(tx.nonce, { const url = provider.getAuthorizationUrl(tx.nonce, {
@@ -129,10 +152,13 @@ async function callback(req, res) {
return res.redirect(loginError('bad_state')) return res.redirect(loginError('bad_state'))
} }
// tx is verified — steer failures back to the portal (and page) the flow began in.
const portal = portalFor(tx.returnTo)
const failFor = (code) => (tx.mode === 'link' ? accountError(code, portal) : loginError(code, portal))
try { try {
const row = await authProviders.getWithSecret(providerId) const row = await authProviders.getWithSecret(providerId)
if (!row || !row.enabled || !registry.validateConfig(row).valid) { if (!row || !row.enabled || !registry.validateConfig(row).valid) {
return res.redirect(loginError('unavailable')) return res.redirect(failFor('unavailable'))
} }
const provider = registry.instantiate(row) const provider = registry.instantiate(row)
const profile = await provider.handleCallback({ const profile = await provider.handleCallback({
@@ -144,19 +170,75 @@ async function callback(req, res) {
return finishLogin(req, res, providerId, row.kind, tx, profile) return finishLogin(req, res, providerId, row.kind, tx, profile)
} catch (err) { } catch (err) {
log.error('sso callback', err) log.error('sso callback', err)
return res.redirect(loginError('error')) return res.redirect(failFor('error'))
} }
} }
// Link-only login: require an existing (provider, subject) identity → session. // Auto-provision a `player` from an SSO profile when no identity is linked yet
async function finishLogin(req, res, providerId, kind, tx, profile) { // and registration allows SSO sign-up. Derives a unique username (reserved-name
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject) // safe) with a bounded retry against the UNIQUE index, captures the provider
if (!identity) { // email, links the identity, and audit-logs the provision. Returns the new user,
log.warn('sso login refused: no linked account', { provider: providerId }) // or null if a unique username couldn't be found.
return res.redirect(loginError('not_linked')) async function provisionSsoPlayer(req, providerId, profile) {
const base = usernamePolicy.deriveUsernameBase(profile)
for (let attempt = 0; attempt < PROVISION_MAX_TRIES; attempt++) {
const candidate = usernamePolicy.candidateUsername(base, attempt)
try {
const user = await users.createUser({
username: candidate,
role: 'player',
email: profile.email || null,
// The built-in providers only return an email the IdP has verified, so
// treat a supplied address as verified (skips the eventual re-verify).
emailVerified: Boolean(profile.email),
})
await userIdentities.link({
userId: user.id,
provider: providerId,
subject: profile.subject,
email: profile.email,
})
await activity.log({ req, userId: user.id, action: 'auth.sso.provision', detail: { provider: providerId } })
log.info('sso player provisioned', { provider: providerId, id: user.id, username: user.username })
return user
} catch (err) {
// Username collided with a concurrent/existing account — try the next
// suffix. Any other error is real; propagate it.
if (users.isDuplicateUsername(err)) continue
throw err
}
}
log.error('sso provision: exhausted username candidates', { provider: providerId, base })
return null
}
// SSO login. Normally link-only: a login succeeds only if the external identity
// is already linked. The one setting-gated relaxation is auto-provisioning a
// player when player_registration ∈ {sso, both} (see provisionSsoPlayer).
async function finishLogin(req, res, providerId, kind, tx, profile) {
const portal = portalFor(tx.returnTo)
let user
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
if (identity) {
user = await users.getById(identity.user_id)
if (!user) return res.redirect(loginError('not_linked', portal))
} else {
// Unknown identity: auto-provision only if registration opts into SSO sign-up.
const mode = await settings.getRegistrationMode()
if (mode !== 'sso' && mode !== 'both') {
log.warn('sso login refused: no linked account', { provider: providerId })
return res.redirect(loginError('not_linked', portal))
}
user = await provisionSsoPlayer(req, providerId, profile)
if (!user) return res.redirect(loginError('error', portal))
}
// Status gate (parity with local login): a disabled/banned account can't
// complete SSO login either.
if (user.status && user.status !== 'active') {
log.warn('sso login refused: inactive account', { provider: providerId, id: user.id, status: user.status })
return res.redirect(loginError('disabled', portal))
} }
const user = await users.getById(identity.user_id)
if (!user) return res.redirect(loginError('not_linked'))
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso' const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
@@ -173,15 +255,15 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
}) })
res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req)) res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req))
log.info('sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip }) log.info('sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip })
return res.redirect('/admin/login?sso_totp=1') return res.redirect(`${loginPath(portal)}?sso_totp=1`)
} }
const { token: sessionToken } = sessionService.createSession(user, authMethod) const { token: sessionToken } = sessionService.createSession(user, authMethod)
token.setAuthCookie(req, res, sessionToken) token.setAuthCookie(req, res, sessionToken)
await users.recordLogin(user.id) await users.recordLogin(user.id, req.ip)
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: providerId } }) await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: providerId } })
log.info('sso login success', { provider: providerId, id: user.id, ip: req.ip }) log.info('sso login success', { provider: providerId, id: user.id, ip: req.ip })
return res.redirect(sanitizeReturn(tx.returnTo) || '/admin') return res.redirect(sanitizeReturn(tx.returnTo) || homePath(portal))
} }
// POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on. // POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on.
@@ -203,18 +285,26 @@ async function finishSsoTotp(req, res) {
return res.status(401).json({ message: 'Invalid verification code.' }) return res.status(401).json({ message: 'Invalid verification code.' })
} }
// Correct second factor, but the account is disabled/banned since the flow
// started — refuse and clear the staged cookie.
if (user.status && user.status !== 'active') {
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
log.warn('sso TOTP refused: inactive account', { id: user.id, status: user.status })
return res.status(403).json({ message: 'This account is not active. Contact an administrator.' })
}
// Second factor satisfied — clear the staged cookie and issue the real session. // Second factor satisfied — clear the staged cookie and issue the real session.
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req)) res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
loginProtection.recordSuccess(req.ip) loginProtection.recordSuccess(req.ip)
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso' const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
const { token: sessionToken } = sessionService.createSession(user, authMethod) const { token: sessionToken } = sessionService.createSession(user, authMethod)
token.setAuthCookie(req, res, sessionToken) token.setAuthCookie(req, res, sessionToken)
await users.recordLogin(user.id) await users.recordLogin(user.id, req.ip)
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: pending.provider, totp: true } }) await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: pending.provider, totp: true } })
log.info('sso login success (2fa)', { provider: pending.provider, id: user.id, ip: req.ip }) log.info('sso login success (2fa)', { provider: pending.provider, id: user.id, ip: req.ip })
return res.json({ return res.json({
user: { id: user.id, username: user.username, role: user.role }, user: { id: user.id, username: user.username, role: user.role },
returnTo: sanitizeReturn(pending.returnTo) || '/admin', returnTo: sanitizeReturn(pending.returnTo) || homePath(portalFor(pending.returnTo)),
}) })
} catch (err) { } catch (err) {
log.error('sso totp error', err) log.error('sso totp error', err)
@@ -225,18 +315,19 @@ async function finishSsoTotp(req, res) {
// Attach the external identity to the account that initiated linking (tx.linkUserId // Attach the external identity to the account that initiated linking (tx.linkUserId
// was captured behind requireAuth at /link start, so the signed tx authorizes it). // was captured behind requireAuth at /link start, so the signed tx authorizes it).
async function finishLink(req, res, providerId, tx, profile) { async function finishLink(req, res, providerId, tx, profile) {
const portal = portalFor(tx.returnTo)
const userId = tx.linkUserId const userId = tx.linkUserId
if (!userId) return res.redirect(loginError('error')) if (!userId) return res.redirect(loginError('error', portal))
const existing = await userIdentities.findByProviderSubject(providerId, profile.subject) const existing = await userIdentities.findByProviderSubject(providerId, profile.subject)
if (existing && existing.user_id !== userId) { if (existing && existing.user_id !== userId) {
return res.redirect(accountError('in_use')) // that external identity belongs to another account return res.redirect(accountError('in_use', portal)) // external identity belongs to another account
} }
if (!existing) { if (!existing) {
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email }) await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } }) await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
log.info('sso account linked', { provider: providerId, userId }) log.info('sso account linked', { provider: providerId, userId })
} }
return res.redirect(`/admin/account?linked=${providerId}`) return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
} }
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishSsoTotp, finishLink } module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishSsoTotp, finishLink }

View File

@@ -0,0 +1,132 @@
// ── Player self-service (role: 'player') ───────────────────────────────────
//
// The player-gated surface. Every route here requires an authenticated session
// whose fresh DB role is 'player' (staff use /admin/account for the same self-
// service). Handlers are shared with the admin account view (account.controller)
// — the same TOTP / identity logic, plus the net-new self-scoped credential
// changes. Future player-only endpoints (profile, etc.) hang off this group.
const express = require('express')
const { body, param } = require('express-validator')
const account = require('../admin/account.controller')
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
const playerRouter = express.Router()
// Group gate: authenticated + fresh role must be 'player', and keep it out of
// search indexes. requireAuth also enforces the account status check (a
// disabled/banned player is rejected here with 403 before any handler runs).
playerRouter.use(noindex, requireAuth, requireRole('player'))
playerRouter.get(
'/account',
// #swagger.tags = ['Player']
// #swagger.summary = 'Get the current player account (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The player account', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerAccount" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.getAccount,
)
playerRouter.patch(
'/account/username',
// #swagger.tags = ['Player']
// #swagger.summary = 'Change the current players username'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeUsernameRequest" } } } } */
/* #swagger.responses[200] = { description: 'Updated username (session cookie re-issued)', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('username').isString().trim().isLength({ min: 3, max: 32 }),
validate,
account.changeUsername,
)
playerRouter.patch(
'/account/password',
// #swagger.tags = ['Player']
// #swagger.summary = 'Change or set the current players password'
// #swagger.description = 'If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the callers session is re-issued (they stay logged in) while all other sessions are revoked.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangePasswordRequest" } } } } */
/* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'Validation error or wrong current password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('newPassword').isString().isLength({ min: 8, max: 64 }),
body('currentPassword').optional({ values: 'falsy' }).isString(),
validate,
account.changePassword,
)
// TOTP self-enrollment — identical to the admin account flow (disable requires a
// valid current code; it does not take a password).
playerRouter.post(
'/account/totp/setup',
// #swagger.tags = ['Player']
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.totpSetup,
)
playerRouter.post(
'/account/totp/enable',
// #swagger.tags = ['Player']
// #swagger.summary = 'Enable 2FA by confirming a code'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpEnable,
)
playerRouter.post(
'/account/totp/disable',
// #swagger.tags = ['Player']
// #swagger.summary = 'Disable 2FA by confirming a code'
// #swagger.description = 'Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpDisable,
)
// Linked SSO identities (self-service). Linking itself starts at
// GET /auth/sso/:provider/link (already behind requireAuth; works for players).
playerRouter.get(
'/account/identities',
// #swagger.tags = ['Player']
// #swagger.summary = 'List linked SSO identities (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
account.listIdentities,
)
playerRouter.delete(
'/account/identities/:provider',
// #swagger.tags = ['Player']
// #swagger.summary = 'Unlink an SSO identity (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('provider').matches(/^[a-z0-9-]+$/),
validate,
account.unlinkIdentity,
)
module.exports = playerRouter

View File

@@ -1,10 +1,21 @@
const posts = require('../../../model/posts/posts.model') const posts = require('../../../model/posts/posts.model')
const wiki = require('../../../model/wiki/wiki.model') const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model') const settings = require('../../../model/settings/settings.model')
const pages = require('../../../model/pages/pages.model')
const mailer = require('../../../utils/mailer') const mailer = require('../../../utils/mailer')
const { getUserFromRequest } = require('../../../utils/auth')
const token = require('../../../auth/token')
const log = require('../../../utils/logger')('public') const log = require('../../../utils/logger')('public')
// Staff (non-player) roles may see draft pages on the public route; everyone else
// gets a 404 for a draft, indistinguishable from a missing page.
const STAFF_ROLES = ['admin', 'editor', 'moderator']
function isStaff(req) {
const user = getUserFromRequest(req)
return Boolean(user && STAFF_ROLES.includes(user.role))
}
async function getSettings(req, res) { async function getSettings(req, res) {
try { try {
return res.json(await settings.getPublic()) return res.json(await settings.getPublic())
@@ -100,6 +111,34 @@ async function getWikiPage(req, res) {
} }
} }
async function getPage(req, res) {
try {
// Staff see drafts (live preview); the public sees published pages only.
const page = await pages.getBySlug(req.params.slug, { includeUnpublished: isStaff(req) })
if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Token-gated draft preview: renders the page's current block state regardless of
// status, for anyone holding the (short-lived, unguessable) link.
async function getPagePreview(req, res) {
try {
const id = Number(req.params.id)
const decoded = token.verifyPagePreview(req.params.token)
if (!decoded || decoded.pageId !== id) {
return res.status(404).json({ message: 'Preview not found or expired' })
}
const page = await pages.getById(id)
if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function contact(req, res) { async function contact(req, res) {
const { name, email, message } = req.body const { name, email, message } = req.body
try { try {
@@ -120,5 +159,7 @@ module.exports = {
getWikiTags, getWikiTags,
getWikiList, getWikiList,
getWikiPage, getWikiPage,
getPage,
getPagePreview,
contact, contact,
} }

View File

@@ -102,4 +102,30 @@ publicRouter.get(
ctrl.getWikiPage, ctrl.getWikiPage,
) )
// ── CMS pages (block-based) ────────────────────────────────────────────
// Preview is registered before /pages/:slug and is NOT site-mode gated, so a
// draft-preview link keeps working during maintenance. The token itself is the
// access control.
publicRouter.get(
'/pages/:id/preview/:token',
// #swagger.tags = ['Public']
// #swagger.summary = 'Render a page from a draft-preview token'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Preview token from POST /admin/pages/:id/preview.' }
/* #swagger.responses[200] = { description: 'The page (any status)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Token invalid/expired or page missing', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.getPagePreview,
)
publicRouter.get(
'/pages/:slug',
// #swagger.tags = ['Public']
// #swagger.summary = 'Get a published CMS page by slug'
// #swagger.description = 'Drafts 404 for the public; staff sessions see drafts. Gated by site mode.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page slug.' }
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getPage,
)
module.exports = publicRouter module.exports = publicRouter

View File

@@ -5,10 +5,12 @@ const v1Router = express.Router()
const authRouter = require('./auth/auth.routes') const authRouter = require('./auth/auth.routes')
const publicRouter = require('./public/public.routes') const publicRouter = require('./public/public.routes')
const adminRouter = require('./admin/admin.routes') const adminRouter = require('./admin/admin.routes')
const playerRouter = require('./player/player.routes')
v1Router.use('/auth', authRouter) v1Router.use('/auth', authRouter)
v1Router.use('/public', publicRouter) v1Router.use('/public', publicRouter)
v1Router.use('/admin', adminRouter) v1Router.use('/admin', adminRouter)
v1Router.use('/player', playerRouter)
// NOTE: /internal is intentionally NOT mounted here. Those routes return the // NOTE: /internal is intentionally NOT mounted here. Those routes return the
// decrypted Discord bot token and must never share the public listener that // decrypted Discord bot token and must never share the public listener that
// Pangolin proxies. They live on a separate, unpublished port via // Pangolin proxies. They live on a separate, unpublished port via

View File

@@ -8,7 +8,6 @@ const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model') const settings = require('./model/settings/settings.model')
const revokedSessions = require('./model/revokedSessions/revokedSessions.model') const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
const mailer = require('./utils/mailer')
const createLogger = require('./utils/logger') const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey') const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const pkg = require('../package.json') const pkg = require('../package.json')
@@ -30,7 +29,7 @@ async function start() {
logFile: createLogger.logFilePath || 'disabled (console only)', logFile: createLogger.logFilePath || 'disabled (console only)',
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'uomysticmoon'}`, db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'uomysticmoon'}`,
cookieSecure: process.env.COOKIE_SECURE || 'auto', cookieSecure: process.env.COOKIE_SECURE || 'auto',
smtp: mailer.isConfigured() ? 'configured' : 'not configured (mailto fallback)', email: 'gmail-oauth2 (configured in admin → settings)',
}) })
// Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in // Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in

View File

@@ -1,41 +1,125 @@
// ── Outbound mail (Gmail over OAuth2 / SMTP XOAUTH2) ───────────────────────
//
// Email is configured in Admin → Settings → Email, not via env vars. The
// connection (enabled flag, connected Gmail address, encrypted refresh token)
// lives in the email_config singleton; the OAuth client id/secret are reused
// from the `google` auth_providers row. nodemailer takes the refresh token and
// auto-mints short-lived access tokens for each send.
//
// When email is not configured, sendContactMessage does NOT throw — it signals
// the caller to fall back to a mailto: link (the contact form relies on this).
const nodemailer = require('nodemailer') const nodemailer = require('nodemailer')
require('dotenv').config()
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, CONTACT_TO } = process.env const emailConfig = require('../model/emailConfig/emailConfig.model')
const authProviders = require('../model/authProviders/authProviders.model')
const settings = require('../model/settings/settings.model')
const log = require('./logger')('mailer')
function isConfigured() { // Ready to send only when enabled, connected (has a refresh token), and we know
return Boolean(SMTP_HOST && CONTACT_TO) // which address to send as.
async function isConfigured() {
const c = await emailConfig.getSafe()
return Boolean(c.enabled && c.hasRefreshToken && c.senderEmail)
} }
let transporter = null // Recipient for the contact form: the admin-editable contact_email setting, or
function getTransporter() { // the connected sending address as a last resort.
if (!transporter) { async function contactRecipient(senderEmail) {
transporter = nodemailer.createTransport({ const to = await settings.get('contact_email')
host: SMTP_HOST, return to || senderEmail || null
port: Number(SMTP_PORT) || 587, }
secure: Number(SMTP_PORT) === 465,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, // Build a nodemailer OAuth2 transport from the stored config + reused Google
// client credentials. Returns { transport, config } or null when unconfigured.
async function buildTransport() {
const config = await emailConfig.getWithSecret()
if (!config || !config.refreshToken || !config.senderEmail) return null
const google = await authProviders.getWithSecret('google')
if (!google || !google.client_id || !google.client_secret) {
log.warn('email send skipped: Google OAuth client is not configured')
return null
}
const transport = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true,
auth: {
type: 'OAuth2',
user: config.senderEmail,
clientId: google.client_id,
clientSecret: google.client_secret,
refreshToken: config.refreshToken,
},
}) })
return { transport, config }
} }
return transporter
function fromHeader(config) {
return config.senderName ? `"${config.senderName}" <${config.senderEmail}>` : config.senderEmail
} }
/** /**
* Send a contact message. If SMTP is not configured, signals the caller to fall * Send a contact message. If email is not configured/enabled, signals the caller
* back to a mailto: link instead of throwing. Credentials come from env only. * to fall back to a mailto: link instead of throwing.
*/ */
async function sendContactMessage({ name, email, message }) { async function sendContactMessage({ name, email, message }) {
if (!isConfigured()) { const built = await buildTransport()
return { sent: false, fallback: 'mailto', email: CONTACT_TO || null } if (!built) {
const c = await emailConfig.getSafe()
return { sent: false, fallback: 'mailto', email: await contactRecipient(c.senderEmail) }
} }
await getTransporter().sendMail({ const { transport, config } = built
from: SMTP_USER || CONTACT_TO, const to = await contactRecipient(config.senderEmail)
to: CONTACT_TO, try {
await transport.sendMail({
from: fromHeader(config),
to,
replyTo: email, replyTo: email,
subject: `UOMysticmoon contact from ${name || 'a visitor'}`, subject: `UOMysticmoon contact from ${name || 'a visitor'}`,
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`, text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
}) })
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Last send OK', lastVerifiedAt: new Date() })
return { sent: true } return { sent: true }
} catch (err) {
log.error('contact send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
throw err
}
} }
module.exports = { isConfigured, sendContactMessage } /**
* Send a test email to `to`, used by the admin "Send test" button. Throws on
* failure; records the outcome either way. Returns { sent: true } on success.
*/
async function sendTest(to) {
const built = await buildTransport()
if (!built) {
const err = new Error('Email is not configured. Connect Gmail first.')
err.code = 'NOT_CONFIGURED'
throw err
}
const { transport, config } = built
const recipient = to || (await contactRecipient(config.senderEmail))
if (!recipient) {
const err = new Error('No recipient available for the test email.')
err.code = 'NO_RECIPIENT'
throw err
}
try {
await transport.sendMail({
from: fromHeader(config),
to: recipient,
subject: 'UOMysticmoon email test',
text: 'This is a test message confirming Gmail OAuth2 email delivery is working.',
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
return { sent: true, to: recipient }
} catch (err) {
log.error('test send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
throw err
}
}
module.exports = { isConfigured, sendContactMessage, sendTest }

View File

@@ -18,6 +18,19 @@ const OPTIONS = {
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3) span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
th: ['colspan', 'rowspan'], th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'],
// Block alignment from the rich-text editor. `style` is only honored for the
// properties/values whitelisted in allowedStyles below — everything else in
// the style attribute is stripped.
p: ['style'],
h1: ['style'], h2: ['style'], h3: ['style'],
h4: ['style'], h5: ['style'], h6: ['style'],
},
// Restrict inline styles to text-align (left/right/center/justify) only. Any
// other CSS property, or an unlisted value, is discarded.
allowedStyles: {
'*': {
'text-align': [/^(left|right|center|justify)$/],
},
}, },
// http/https for links and images, mailto for links, plus relative URLs so // http/https for links and images, mailto for links, plus relative URLs so
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through. // uploaded images (/uploads/...) and internal links (/wiki/...) pass through.

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@ const doc = {
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' }, { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' }, { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' }, { name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' }, { name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
@@ -113,6 +114,16 @@ const doc = {
company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' }, company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
}, },
}, },
RegisterRequest: {
type: 'object',
required: ['username', 'password'],
properties: {
username: { type: 'string', minLength: 3, maxLength: 32, example: 'newplayer' },
password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
},
},
LoginResponse: { LoginResponse: {
type: 'object', type: 'object',
description: description:
@@ -340,7 +351,10 @@ const doc = {
properties: { properties: {
id: { type: 'integer', example: 1 }, id: { type: 'integer', example: 1 },
username: { type: 'string', example: 'admin' }, username: { type: 'string', example: 'admin' },
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' }, role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'admin' },
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
email: { type: 'string', format: 'email', nullable: true },
email_verified: { type: 'boolean', example: false },
totp_enabled: { type: 'boolean', example: true }, totp_enabled: { type: 'boolean', example: true },
last_login_at: { type: 'string', format: 'date-time', nullable: true }, last_login_at: { type: 'string', format: 'date-time', nullable: true },
created_at: { type: 'string', format: 'date-time' }, created_at: { type: 'string', format: 'date-time' },
@@ -352,9 +366,53 @@ const doc = {
properties: { properties: {
username: { type: 'string', minLength: 3, maxLength: 32, example: 'editor1' }, username: { type: 'string', minLength: 3, maxLength: 32, example: 'editor1' },
password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 }, password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
role: { type: 'string', enum: ['admin', 'editor'], example: 'editor' }, role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'editor' },
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
email: { type: 'string', format: 'email', nullable: true },
}, },
}, },
// Player self-service credential changes (/api/v1/player/account/*).
ChangeUsernameRequest: {
type: 'object',
required: ['username'],
properties: {
username: { type: 'string', minLength: 3, maxLength: 32, example: 'newname' },
},
},
ChangePasswordRequest: {
type: 'object',
required: ['newPassword'],
properties: {
newPassword: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
currentPassword: {
type: 'string',
format: 'password',
description:
'Required when the account already has a password. Omit only for an SSO-provisioned account setting its first password.',
},
},
},
PlayerAccount: {
type: 'object',
description: 'Self-service player account (GET /player/account).',
properties: {
id: { type: 'integer', example: 42 },
username: { type: 'string', example: 'newplayer' },
role: { type: 'string', enum: ['player'], example: 'player' },
email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
totp_enabled: { type: 'boolean', example: false },
has_password: {
type: 'boolean',
description: 'False for an SSO-provisioned account that has not set a password yet.',
example: true,
},
},
},
OkFlag: {
type: 'object',
properties: { ok: { type: 'boolean', example: true } },
},
TotpCodeRequest: { TotpCodeRequest: {
type: 'object', type: 'object',
required: ['code'], required: ['code'],

View File

@@ -0,0 +1,70 @@
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const emailConfig = require('../src/model/emailConfig/emailConfig.model')
const emailDb = require('../src/model/emailConfig/emailConfig.db')
const secretBox = require('../src/utils/secretBox')
const db = require('../src/utils/db')
after(() => db.close())
// In-memory stand-in for the singleton row so the model never touches MariaDB.
let store
beforeEach(() => {
store = null
emailDb.get = async () => store
emailDb.upsert = async (fields) => {
store = { ...(store || { id: 1 }), ...fields }
return store
}
})
test('save encrypts the refresh token (ciphertext at rest, decryptable)', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
assert.ok(store.refresh_token_enc)
assert.notEqual(store.refresh_token_enc, 'refresh-abc')
assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc')
const withSecret = await emailConfig.getWithSecret()
assert.equal(withSecret.refreshToken, 'refresh-abc')
})
test('getSafe never leaks the refresh token', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
const safe = await emailConfig.getSafe()
assert.equal(safe.hasRefreshToken, true)
assert.equal(safe.senderEmail, 'me@gmail.com')
assert.equal('refreshToken' in safe, false)
assert.equal('refresh_token_enc' in safe, false)
})
test('blank refresh token on save leaves the existing one unchanged', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
const cipherBefore = store.refresh_token_enc
await emailConfig.save({ senderName: 'UOMysticmoon' }) // no refreshToken
assert.equal(store.refresh_token_enc, cipherBefore) // untouched
assert.equal(store.sender_name, 'UOMysticmoon')
assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc')
})
test('disconnect clears the credential and disables sending', async () => {
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
const safe = await emailConfig.disconnect(7)
assert.equal(store.refresh_token_enc, null)
assert.equal(store.enabled, 0)
assert.equal(safe.hasRefreshToken, false)
assert.equal(safe.status, 'unconfigured')
})
test('getSafe returns unconfigured defaults when no row exists', async () => {
const safe = await emailConfig.getSafe()
assert.equal(safe.enabled, false)
assert.equal(safe.hasRefreshToken, false)
assert.equal(safe.status, 'unconfigured')
assert.equal(safe.senderEmail, null)
})

View File

@@ -0,0 +1,72 @@
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const nodemailer = require('nodemailer')
const emailConfig = require('../src/model/emailConfig/emailConfig.model')
const authProviders = require('../src/model/authProviders/authProviders.model')
const settings = require('../src/model/settings/settings.model')
const mailer = require('../src/utils/mailer')
const db = require('../src/utils/db')
after(() => db.close())
// Restore a clean slate of stubs before each test.
beforeEach(() => {
emailConfig.recordStatus = async () => {}
settings.get = async () => 'contact@example.com'
})
test('unconfigured → mailto fallback (never throws)', async () => {
emailConfig.getWithSecret = async () => null
emailConfig.getSafe = async () => ({ senderEmail: null, hasRefreshToken: false, enabled: false })
const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi' })
assert.deepEqual(r, { sent: false, fallback: 'mailto', email: 'contact@example.com' })
})
test('configured → builds a Gmail OAuth2 transport and sends', async () => {
let transportCfg = null
let sent = null
nodemailer.createTransport = (cfg) => {
transportCfg = cfg
return { sendMail: async (opts) => { sent = opts; return { messageId: '1' } } }
}
emailConfig.getWithSecret = async () => ({ refreshToken: 'rt-123', senderEmail: 'shard@gmail.com', senderName: 'UOMysticmoon' })
authProviders.getWithSecret = async (id) => {
assert.equal(id, 'google')
return { client_id: 'cid', client_secret: 'csec' }
}
const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi there' })
assert.equal(r.sent, true)
// Transport is Gmail SMTP over XOAUTH2 with the reused Google client + stored refresh token.
assert.equal(transportCfg.host, 'smtp.gmail.com')
assert.equal(transportCfg.port, 465)
assert.equal(transportCfg.secure, true)
assert.equal(transportCfg.auth.type, 'OAuth2')
assert.equal(transportCfg.auth.user, 'shard@gmail.com')
assert.equal(transportCfg.auth.clientId, 'cid')
assert.equal(transportCfg.auth.clientSecret, 'csec')
assert.equal(transportCfg.auth.refreshToken, 'rt-123')
// From uses the display name; To is the contact_email setting; replyTo is the sender.
assert.equal(sent.from, '"UOMysticmoon" <shard@gmail.com>')
assert.equal(sent.to, 'contact@example.com')
assert.equal(sent.replyTo, 'ann@player.com')
})
test('send failure propagates and is recorded', async () => {
let recorded = null
emailConfig.recordStatus = async (s) => { recorded = s }
nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('smtp boom') } })
emailConfig.getWithSecret = async () => ({ refreshToken: 'rt', senderEmail: 'shard@gmail.com', senderName: null })
authProviders.getWithSecret = async () => ({ client_id: 'cid', client_secret: 'csec' })
await assert.rejects(() => mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' }), /smtp boom/)
assert.equal(recorded.status, 'error')
})

View File

@@ -0,0 +1,112 @@
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
// so the one branch that reaches the DB fails fast instead of hanging the runner.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const bcrypt = require('bcryptjs')
const authCtrl = require('../src/router/v1/auth/auth.controller')
const account = require('../src/router/v1/admin/account.controller')
const users = require('../src/model/users/users.model')
const settings = require('../src/model/settings/settings.model')
const botScore = require('../src/middleware/botScore')
const lp = require('../src/middleware/loginProtection')
const db = require('../src/utils/db')
after(() => db.close())
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
set() {
return this
},
cookie() {
return this
},
}
}
beforeEach(() => {
botScore._reset()
lp._reset()
})
// ── Derived public registration flags ─────────────────────────────────────
test('registrationFlags maps each mode to password/sso booleans', () => {
assert.deepEqual(settings.registrationFlags('disabled'), { password: false, sso: false })
assert.deepEqual(settings.registrationFlags('password'), { password: true, sso: false })
assert.deepEqual(settings.registrationFlags('sso'), { password: false, sso: true })
assert.deepEqual(settings.registrationFlags('both'), { password: true, sso: true })
})
test('REGISTRATION_MODES is the closed set of allowed values', () => {
assert.deepEqual(settings.REGISTRATION_MODES, ['disabled', 'password', 'sso', 'both'])
})
// ── Null-hash password rule ────────────────────────────────────────────────
test('validatePassword rejects an SSO-only account with a null hash', async () => {
assert.equal(await users.validatePassword({ password_hash: null }, 'anything'), false)
assert.equal(await users.validatePassword(null, 'anything'), false)
})
test('validatePassword accepts a correct password against a real hash', async () => {
const password_hash = await bcrypt.hash('correct horse', 10)
assert.equal(await users.validatePassword({ password_hash }, 'correct horse'), true)
assert.equal(await users.validatePassword({ password_hash }, 'wrong'), false)
})
test('isDuplicateUsername recognizes the driver duplicate-key error', () => {
assert.equal(users.isDuplicateUsername({ code: 'ER_DUP_ENTRY' }), true)
assert.equal(users.isDuplicateUsername({ errno: 1062 }), true)
assert.equal(users.isDuplicateUsername({ code: 'ER_NO_SUCH_TABLE' }), false)
assert.equal(users.isDuplicateUsername(null), false)
})
// ── getAccount.has_password reads the RAW row ─────────────────────────────
// Regression: req.user is the sanitized row (password_hash stripped), so
// has_password must come from users.getRawById, not req.user.password_hash —
// otherwise a real password account is mis-rendered as "set a password".
test('getAccount reports has_password from the raw row, not the sanitized req.user', async () => {
const origGetRaw = users.getRawById
try {
users.getRawById = async () => ({ id: 1, password_hash: '$2a$hash' }) // has a password
const req = { user: { id: 1, username: 'p', role: 'player', status: 'active', totp_enabled: 0 } } // sanitized: no hash
const res = mockRes()
await account.getAccount(req, res)
assert.equal(res.body.has_password, true)
users.getRawById = async () => ({ id: 1, password_hash: null }) // SSO-only, no password
const res2 = mockRes()
await account.getAccount(req, res2)
assert.equal(res2.body.has_password, false)
} finally {
users.getRawById = origGetRaw
}
})
// ── Registration honeypot (does not need the DB) ──────────────────────────
test('register with a filled honeypot fails and bans the IP before any DB hit', async () => {
const ip = '203.0.113.90'
const req = {
ip,
body: { username: 'newplayer', password: 'password123', [authCtrl.HONEYPOT_FIELD]: 'Acme' },
}
const res = mockRes()
await authCtrl.register(req, res)
assert.equal(res.statusCode, 400)
assert.doesNotMatch(res.body.message, /honeypot|bot|company/i)
assert.equal(botScore.isBanned(ip), true)
})

View File

@@ -14,6 +14,7 @@ const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model') const activity = require('../src/model/activity/activity.model')
const authProviders = require('../src/model/authProviders/authProviders.model') const authProviders = require('../src/model/authProviders/authProviders.model')
const userIdentities = require('../src/model/userIdentities/userIdentities.model') const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const settings = require('../src/model/settings/settings.model')
const registry = require('../src/auth/providers/registry') const registry = require('../src/auth/providers/registry')
const totp = require('../src/utils/totp') const totp = require('../src/utils/totp')
const db = require('../src/utils/db') const db = require('../src/utils/db')
@@ -34,6 +35,9 @@ beforeEach(() => {
userIdentities.link = async () => 1 userIdentities.link = async () => 1
users.getById = async (id) => ({ id, username: 'alice', role: 'admin' }) users.getById = async (id) => ({ id, username: 'alice', role: 'admin' })
users.recordLogin = async () => {} // avoid the real DB on the success path users.recordLogin = async () => {} // avoid the real DB on the success path
// Default: registration closed, so login stays strictly link-only unless a
// test opts into SSO sign-up.
settings.getRegistrationMode = async () => 'disabled'
}) })
function mockRes() { function mockRes() {
@@ -87,6 +91,39 @@ test('UNLINKED identity → no session, redirect to not_linked (link-only policy
assert.equal(logged.length, 0) assert.equal(logged.length, 0)
}) })
test('UNLINKED identity + SSO sign-up enabled → auto-provisions a player and logs in', async () => {
settings.getRegistrationMode = async () => 'both'
userIdentities.findByProviderSubject = async () => null
let created = null
users.createUser = async (args) => {
created = args
return { id: 42, username: args.username, role: 'player', status: 'active' }
}
let linkArgs = null
userIdentities.link = async (args) => { linkArgs = args; return 1 }
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.equal(created.role, 'player')
assert.equal(created.email, 'alice@example.com')
assert.equal(linkArgs.userId, 42)
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set for the new player')
assert.equal(res.redirectedTo, '/admin')
// Both the provision and the login are audited.
assert.deepEqual(logged.map((e) => e.action), ['auth.sso.provision', 'auth.sso.login'])
})
test('UNLINKED identity from the player portal lands back in /account', async () => {
settings.getRegistrationMode = async () => 'both'
userIdentities.findByProviderSubject = async () => null
users.createUser = async (args) => ({ id: 43, username: args.username, role: 'player', status: 'active' })
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/account' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.equal(res.redirectedTo, '/account')
})
test('link mode → identity linked to the acting user, redirect to account', async () => { test('link mode → identity linked to the acting user, redirect to account', async () => {
let linkArgs = null let linkArgs = null
userIdentities.link = async (args) => { linkArgs = args; return 1 } userIdentities.link = async (args) => { linkArgs = args; return 1 }

View File

@@ -0,0 +1,52 @@
// Unit tests for the pure username policy (no DB): validation, reserved-name
// blocklist, case normalization, SSO derivation + the dedup suffix loop.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const policy = require('../src/auth/usernamePolicy')
test('validateUsername accepts a normal name and trims whitespace', () => {
const r = policy.validateUsername(' Frodo_99 ')
assert.equal(r.ok, true)
assert.equal(r.name, 'Frodo_99') // trimmed, case preserved
})
test('validateUsername rejects too-short / too-long / bad-charset names', () => {
assert.equal(policy.validateUsername('ab').ok, false) // < 3
assert.equal(policy.validateUsername('x'.repeat(33)).ok, false) // > 32
assert.equal(policy.validateUsername('has space').ok, false)
assert.equal(policy.validateUsername('emoji😀here').ok, false)
})
test('reserved names are rejected case-insensitively', () => {
for (const name of ['admin', 'ADMIN', 'Administrator', 'root', 'moderator', 'support', 'me']) {
assert.equal(policy.isReserved(name), true, `${name} should be reserved`)
assert.equal(policy.validateUsername(name).ok, false, `${name} should be rejected`)
}
assert.equal(policy.isReserved('frodo'), false)
})
test('sanitizeToUsername strips disallowed chars and leading punctuation', () => {
assert.equal(policy.sanitizeToUsername('Fró.do Baggins!'), 'Fro.doBaggins')
assert.equal(policy.sanitizeToUsername('...weird'), 'weird')
assert.equal(policy.sanitizeToUsername('a'.repeat(50)).length, policy.MAX_LEN)
})
test('deriveUsernameBase prefers display name, then email local-part, then player', () => {
assert.equal(policy.deriveUsernameBase({ name: 'Gandalf', email: 'g@x.com' }), 'Gandalf')
assert.equal(policy.deriveUsernameBase({ name: '💥', email: 'samwise@shire.net' }), 'samwise')
assert.equal(policy.deriveUsernameBase({ name: '', email: '' }), 'player')
// A reserved derived base is skipped in favor of the next candidate.
assert.equal(policy.deriveUsernameBase({ name: 'admin', email: 'realuser@x.com' }), 'realuser')
})
test('candidateUsername yields the base then increasing suffixes, clamped to length', () => {
assert.equal(policy.candidateUsername('bilbo', 0), 'bilbo')
assert.equal(policy.candidateUsername('bilbo', 1), 'bilbo2')
assert.equal(policy.candidateUsername('bilbo', 2), 'bilbo3')
// Long base: the numeric suffix must survive the MAX_LEN clamp.
const long = 'a'.repeat(policy.MAX_LEN)
const c = policy.candidateUsername(long, 10)
assert.ok(c.length <= policy.MAX_LEN)
assert.ok(c.endsWith('11'))
})