118 Commits

Author SHA1 Message Date
493843241e Merge pull request 'refactor(server): split admin moderation, bot-activity and activity into capability routers (PR 2)' (#103) from refactor/admin-router-split-2 into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 35s
SonarQube / analysis (push) Successful in 2m40s
Reviewed-on: #103
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 00:12:02 +00:00
bd53a0b8a4 refactor(server): split admin moderation, bot-activity and activity into capability routers
All checks were successful
PR Checks / bot-install (pull_request) Successful in 24s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 46s
PR 2 of the domain split (docs/website/API_V2_PLAN.md § Phase 2). Carves 18 more
routes out of admin.routes.js into one router file per business capability,
in place, with every URL unchanged:

  moderation.router.js   (15)  /admin/moderation    modAccess at router level
  botActivity.router.js   (2)  /admin/bot-activity  adminOnly per route
  activity.router.js      (1)  /admin/activity      staff-wide, no extra gate

The residual admin.routes.js drops from 82 routes to 64.

Moderation was already gated by a prefix mount (adminRouter.use('/moderation',
modAccess)), so moderationRouter.use(modAccess) is the exact equivalent now that
the router is mounted at a prefix. Bot-activity's adminOnly was per-route and is
deliberately kept per-route: that is what holds the per-route handler count in
routes.guards.json, the only signal that would catch a dropped gate, since
requireRole(...) returns an anonymous arrow and never appears by name.

/activity gets its own file rather than waiting for dashboard.router.js in PR 4
— it is the staff audit log, a different capability from the dashboard's stats
overview and from the botScore middleware's in-memory ban state.

Acceptance:
  - routes.manifest.json  zero-diff (200 public + 2 internal)
  - routes.guards.json    zero-diff
  - swagger-output.json   zero-diff (198 operations)
  - api-route-inventory.json already in sync
  - 434 server tests green
  - role gates verified identical to main by reading the requireRole role sets
    off the live Express stack for every moved route plus untouched controls

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 18:53:15 -05:00
0e11e28cca Merge pull request 'build(swagger): normalize and sort generated OpenAPI path keys' (#101) from build/swagger-normalize-paths into main
All checks were successful
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 53s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m38s
Reviewed-on: #101
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 21:00:57 +00:00
f7c98b8ba3 Merge pull request 'refactor(server): split admin users, account, invites and auth providers into capability routers' (#102) from refactor/admin-router-split-1 into build/swagger-normalize-paths
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 41s
Reviewed-on: #102
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 21:00:03 +00:00
8ad892725f refactor(server): split admin users, account, invites and auth providers into capability routers
First of the five domain-split PRs in docs/website/API_V2_PLAN.md § Phase 2. Pure
mechanical re-wiring: routes move between files, no handler, gate, validator or
annotation changes, and not one URL moves.

New src/router/v1/admin/index.js owns the two things the group shares — the
`noindex, isLoggedIn, staffOnly` gate and the mount table — and declares no routes
itself. The gate sits ahead of every mount so a capability router extracted in a
later PR cannot silently ship without it. Four capability routers mount at the
prefix they already owned inside the monolith:

  account.router.js        6 routes  -> /admin/account   (self-service, no adminOnly)
  users.router.js         15 routes  -> /admin/users     (adminOnly, router-level)
  invites.router.js        3 routes  -> /admin/invites   (adminOnly, per-route)
  authProviders.router.js  4 routes  -> /admin/auth      (adminOnly, per-route)

admin.routes.js keeps the other 82 (6+15+3+4+82 = the 110 inventoried admin
routes) and is mounted last at the group root; none of the four prefixes appears
in it, so nothing depends on mount ordering. It disappears when PR 5 lands.

Handlers still live in admin.controller.js and usersShard.controller.js — this
re-wires routes, not logic. `adminOnly` moves with the routes that use it, and
`usersRouter.use(adminOnly)` is exactly equivalent to the old
`adminRouter.use('/users', adminOnly)` now that the router is mounted at /users.

All three generated gates are zero-diff:

  routes.manifest.json    unchanged (200 public + 2 internal)
  routes.guards.json      unchanged — no route lost or gained a gate
  swagger-output.json     unchanged, byte-for-byte

The spec staying byte-identical depends on the path normalization landed in the
preceding commit; without it the four collection routes would have documented as
/api/v1/admin/{users,invites,account}/ with a trailing slash.

Server tests green (434/434).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:54:00 -05:00
1a61cd1638 build(swagger): normalize and sort generated OpenAPI path keys
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 9m16s
Prepares the committed spec for the admin router domain split
(docs/website/API_V2_PLAN.md § Phase 2) by post-processing swagger-autogen's
output in swagger/swagger.js. No route, handler or annotation changes.

Trailing slashes are stripped from path keys. swagger-autogen builds a path by
string-concatenating the mount prefix with the route argument, so a capability
router mounted at /users whose collection route is router.get('/') documents as
/api/v1/admin/users/ — advertising a URL no client calls while dropping the one
the SPA, the Android app and the Discord bot all do. Express is indifferent
(non-strict routing treats the two as one route, and routes.manifest.json records
the canonical slash-less form), but the published spec is a contract. The split
creates one of these per capability router, so it is fixed once here rather than
by contorting the route declarations in every router file.

Path keys are also sorted. The generator emits them in router-traversal order, so
moving a route between files rewrites most of this ~5k-line committed artifact
even when the API is provably unchanged, burying the one line a reviewer needs to
see. OpenAPI attaches no meaning to path order, and scripts/routeManifest.js
already sorts for the same reason.

Verified inert: the regenerated spec is byte-for-byte the sorted form of the
previously committed one — same 198 operations, zero added or removed, and no
trailing-slash keys (there were none to strip yet; the guard is for the split).
A collision after normalization throws rather than silently dropping an
operation. Server tests green (434/434).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:49:31 -05:00
0dc5af0d8b Merge pull request 'feat(security): soak the tightened CSP on report-only, with a same-origin sink' (#100) from feature/csp-report-only into main
All checks were successful
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 1m20s
Build container images / deploy (push) Successful in 42s
SonarQube / analysis (push) Successful in 2m44s
Reviewed-on: #100
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 20:31:31 +00:00
9b74999610 feat(security): soak the tightened CSP on report-only, with a same-origin sink
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 9m15s
Phase 1 of docs/website/API_V2_PLAN.md. The tightened policy ships on
Content-Security-Policy-Report-Only alongside the unchanged enforced one for a
release; a follow-up PR flips it after the soak comes back clean.

The plan expected a two-directive delta. It is one. `form-action 'self'` was
described as absent because it is not in the directives object in app.js — but
the middleware runs with `useDefaults: true` and helmet's defaults already
supply it, so the header served in production has carried it all along. Caught
by capturing the live header from the running app instead of reading the config.
It is now written out explicitly in config/csp.js regardless: a security
directive should not depend on a third-party library's default surviving its
next major version. The enforced header's contents do not change at all, and a
test pins it verbatim.

So the whole behavioural delta is `frame-ancestors 'self'` -> `'none'`. That is
still the directive most worth soaking: a frame-ancestors report is generated by
the browser of whoever framed the site, which is the only way to find out that
something legitimately embeds us before an enforcing policy breaks it.

The policies move to config/csp.js, with the report-only one derived by spread
from the enforced one so the two cannot drift and the object reads as a diff.

`report-to` needs somewhere to point, so this adds POST /api/csp-report --
same-origin on purpose, since reports describe attacks against this site and
should not go to a third-party collector. It is mounted outside /api/v1 next to
/api/health: the browser learns the path from the policy header, never from a
client build, so it is not versioned client contract.

It is necessarily unauthenticated -- browsers send reports with no session, and
gating it would silence exactly the anonymous visitors worth hearing about -- so
it is bounded on every axis:

  * both wire formats, since report-uri (Firefox/Safari) sends hyphenated keys
    in application/csp-report and report-to (Chrome) sends camelCase envelopes
    in application/reports+json; handling one silently drops half the browsers,
  * report-to also needs the Reporting-Endpoints response header or it is inert,
  * 16 KB body cap, per-IP rate limit, fixed field allowlist, every logged field
    truncated (script-sample is attacker-influenced and can carry a whole inline
    script),
  * always 204, even for malformed input: a 4xx would reach the global error
    handler, which logs the offending body -- turning an open endpoint into a
    log-flood primitive.

Nothing is persisted; reports go to the `csp` log tag.

routes.manifest.json moves 199 -> 200, which is the freeze from PR 0 working as
designed: the one new URL is visible as a reviewed +1 rather than slipping
through. Swagger regenerated to match.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:19:26 -05:00
49b70ee04d Merge pull request 'chore(server): freeze the URL surface with a generated route manifest (PR 0)' (#99) from chore/route-manifest into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
SonarQube / analysis (push) Successful in 2m28s
Build container images / build (push) Successful in 1m15s
Build container images / deploy (push) Successful in 41s
Reviewed-on: #99
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 20:07:29 +00:00
1079b3fc05 chore(server): freeze the URL surface with a generated route manifest
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m40s
PR 0 of the router domain split (docs/website/API_V2_PLAN.md § Phase 2). The
split promises that admin.routes.js can be carved into one router file per
business capability without moving a single URL. That promise has to be proved
by a diff, not asserted in review — this lands the tool that proves it, with no
router file moved.

scripts/routeManifest.js walks the live Express stack (runtime introspection,
not source parsing: route paths in admin.routes.js sit on the line *after*
`adminRouter.get(`, which defeats greps) and writes a sorted { method, path }
list to routes.manifest.json. It reproduces the frozen baseline in
docs/website/api-route-inventory.json byte-for-byte — 199 public routes plus 2
on the internal listener — so the freeze is confirmed accurate, not just
claimed.

Scope is /api/** and /.well-known/** plus the internal app. The SPA catch-all,
/uploads and /brand are filesystem-conditional static mounts, so including them
would make the output depend on whether CI had built the client. Static mounts
are not API contract.

Also emits routes.guards.json — a review aid, not a contract: per route, the
handler count and the *named* middleware on its mount chain. Router-level
`use(noindex, isLoggedIn, staffOnly)` gates never appear in an individual
route's own stack, so an extracted capability router that forgot to re-apply
one would otherwise publish authenticated endpoints silently. Names are a hint
only (requireRole(...) returns an anonymous arrow), but a vanished requireAuth
is unambiguous — and the test suite asserts every /admin/** and /player/**
route still carries it.

The plan's optional unauthenticated-status snapshot was tried and dropped, as
it allowed: against the dead-port mariadb pool the tests use, the sweep sits on
the pool's acquire timeout and had not finished after two minutes. A flaky
two-minute gate is worse than none; the requireAuth assertion covers the same
regression deterministically.

CI runs `npm run routes:manifest -- --check` on every PR, so a URL change can
only merge by deliberately committing the new manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:55:38 -05:00
cbe54fcc91 Merge pull request 'ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main' (#98) from chore/sync-project-tree-ci into main
All checks were successful
sync-project-tree / sync (push) Successful in -6s
Build container images / build (push) Successful in 1m17s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m46s
Reviewed-on: #98
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:31:48 +00:00
9f9bcc6f6e ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 9m33s
Add a sync-project-tree workflow that regenerates this repo's tracked-file
tree and opens (or force-updates) a PR against RunicGateway/docs whenever the
layout on main changes. Never writes to the docs repo's main directly. Reuses
the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in
.gitea/scripts/gen_tree.py (deterministic, dirs-first ordering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:21:22 -05:00
ebfae765d9 Merge pull request 'fix(moderation): windowValue must not fall back to the 30d total on a null column' (#97) from fix/window-value-null-column into main
All checks were successful
Build container images / build (push) Successful in 54s
Build container images / deploy (push) Successful in 36s
SonarQube / analysis (push) Successful in 2m27s
Reviewed-on: #97
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 18:15:46 +00:00
c075ab981c fix(moderation): windowValue must not fall back to the 30d total on a null column
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 39s
windowValue mapped only the 24h/7d keys and used `?? row.d30` as the fallback:

    const col = { '24h': row.d1, '7d': row.d7 }[key] ?? row.d30

so a null d1/d7 (which the function is documented to tolerate) returned the
30-day count instead of 0, inflating the 24h/7d moderation tiles. It happens to
be masked today because `SUM(created_at >= ?)` nulls d1/d7/d30 only in unison,
but the contract is wrong and the existing test used an all-null row that hid it.

Map all three window keys explicitly so each reads its own column and a null
coerces to 0 via `Number(col) || 0`. Add a regression test with a null narrow
column and a non-null d30.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 13:14:02 -05:00
bcdba4ce0a Merge pull request 'fix(admin): restore digit match in discordId route validation' (#96) from fix/discord-id-validation-regex into main
All checks were successful
Build container images / build (push) Successful in 1m9s
Build container images / deploy (push) Successful in 46s
SonarQube / analysis (push) Successful in 2m36s
Reviewed-on: #96
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 18:11:42 +00:00
e08c0c9736 fix(admin): restore digit match in discordId route validation
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m30s
The `:discordId` param validator on the five admin moderation routes used
`/^d{1,32}$/`, which matches 1-32 literal `d` characters instead of digits.
A real numeric Discord snowflake failed validation, so every
`/moderation/user/:discordId*` endpoint returned a 400 for valid input.

The backslash was dropped in a prior code-smell cleanup (12d50fd) that
intended `[0-9]` -> `\d`. Restore `\d` so the regex matches digits again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 13:01:12 -05:00
5fe7032567 Merge pull request 'fix(ntfy): publish ntfy host port so the external reverse proxy can reach it' (#95) from fix/ntfy-published-port into main
All checks were successful
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 34s
SonarQube / analysis (push) Successful in 2m31s
Reviewed-on: #95
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 09:08:13 +00:00
4151f7d44e fix(ntfy): publish ntfy host port so the external reverse proxy can reach it
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 9m29s
The ntfy service was configured with no published host port, on the
assumption that the public reverse proxy shares the compose network and
can dial ntfy:80 directly. It does not — Pangolin runs outside the
compose network and reaches every service through a published host port
(exactly why `app` publishes 3000). With no published port there was
nothing for the notification subdomain to forward to, so push delivery
could never work in production.

Publish container :80 on a host port (NTFY_HOST_PORT, default 2586,
binds 0.0.0.0 like `app`) and correct the now-inaccurate comments in
docker-compose.yml and ntfy/server.yml. Document NTFY_HOST_PORT in
.env.example. No code change — deploy config only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 03:57:32 -05:00
4f1a4902e8 Merge pull request 'fix(player): open the player self-service surface to staff' (#94) from fix/staff-player-self-service into main
All checks were successful
SonarQube / analysis (push) Successful in 2m40s
Build container images / build (push) Successful in 22s
Build container images / deploy (push) Successful in 38s
Reviewed-on: #94
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:36:13 +00:00
14dfc122ba fix(player): open the player self-service surface to staff
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 9m28s
Staff are a superset of players — every player ability plus their staff
tools on top — but the /player/* group ran requireRole('player'), so a
signed-in admin/editor/moderator got 403 on their own linked game
accounts (e.g. GET /player/shard/accounts). On the Android client this
hid "My characters" and greyed the personal notification streams for
staff accounts, even when they had linked characters.

Drop the role gate: the group is now requireAuth-only. Every handler is
already self-scoped to the caller by req.user.id (with the pre-existing
isAdmin bypass still letting a genuine admin read any character), so this
only ever widens access to the caller's OWN data. Staff also reach the
identical self-scoped handlers under /admin/shard/* (same controller).

- player.routes.js: requireRole('player') -> requireAuth; corrected the
  five stale "Player role required" 403 descriptions and regenerated
  swagger-output.json.
- New test/playerRouteAccess.test.js mounts the router and asserts
  player/admin/editor/moderator all reach the handler, anon still 401s,
  and a disabled account still 403s. Suite: 420 pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:17:29 -05:00
514bc9d23c Merge pull request 'feat(auth): trusted devices, recovery codes, and admin MFA management' (#93) from feature/trusted-devices-mfa into main
All checks were successful
Build container images / build (push) Successful in 1m3s
Build container images / deploy (push) Successful in 36s
SonarQube / analysis (push) Successful in 2m32s
Reviewed-on: #93
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 05:09:04 +00:00
60ebacff2c feat(auth): trusted devices, recovery codes, and admin MFA management
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s
Add opt-in "Trust this device" so a browser/app skips the TOTP step (never
the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout
fallback, and admin trusted-device/MFA-reset management — backend, web UI,
OpenAPI spec, and tests.

- Schema: trusted_devices (sha256 token hash, looked up by unique index) and
  recovery_codes (bcrypt, single-use). Both additive/idempotent.
- Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust
  httpOnly cookie (survives logout, revoked on untrust/password change/reset/
  TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim.
- Web + mobile login accept a trusted-device token / recovery code; login/totp
  gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an
  over-cap trust returns 409/trustLimitReached and the client prompts to revoke.
- Self-service /auth/me/trusted-devices* + recovery-codes*; admin
  /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged.
- Client: "Trust this device" + recovery-code login options, one-time recovery
  code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled
  revoke-to-continue cap modal, and admin per-user security controls.
- OpenAPI regenerated; 33 new server tests (all suites green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:38:48 -05:00
8d5bdc0d6e Merge pull request 'docs(readme): add architecture mermaid diagram' (#92) from docs/website-architecture-diagram into main
All checks were successful
Build container images / build (push) Successful in 1m5s
Build container images / deploy (push) Successful in 40s
SonarQube / analysis (push) Successful in 2m23s
Reviewed-on: #92
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 02:17:16 +00:00
c991a07c8a docs(readme): add architecture mermaid diagram
All checks were successful
PR Checks / bot-install (pull_request) Successful in 14s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 35s
Add an Architecture section with a Mermaid diagram of the full data path
(SPA/mobile clients -> layered Express backend -> MariaDB, and the uo-link
sidecar bridge to the ServUO shard) plus a Contents entry. Same diagram is
mirrored in the docs repo (docs/website/ARCHITECTURE.md).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 21:13:11 -05:00
4c13706958 Merge pull request 'chore(dev): stub OAuth IdP tooling for local mobile SSO testing' (#91) from feat/m10-native-sso-fix into main
All checks were successful
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 11m55s
Reviewed-on: #91
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 21:42:57 +00:00
2306545574 chore(dev): add viewport meta to the stub IdP picker page
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m47s
So the dev stub IdP's account-picker renders at the correct mobile size when it
opens in an Android Custom Tab during SSO testing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 16:28:46 -05:00
70849f96ee chore(dev): add stub OAuth IdP + seed + bridge smoketest for native SSO
Dev environments have no real OAuth provider configured, so GET /auth/providers
returns [] and the native mobile SSO flow cannot be exercised locally. Add
dependency-free dev tooling under scripts/dev/:

- stub-idp.js: stub OAuth2/OIDC IdP (authorize picker, token, userinfo)
- seed-sso-provider.js: registers a 'devstub' auth_providers row + pre-links
  each principal's sub to a dev account (SSO is link-only)
- sso-bridge-smoketest.js: drives the full app flow headless (PKCE → start →
  IdP → callback → deep link → exchange) and asserts a bearer pair
- README.md: host + emulator usage

Verified end-to-end against the local site: player and admin principals both
sign in and receive the correct role. DEV ONLY — never deploy the stub.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 14:56:43 -05:00
1edef8e6db Merge pull request 'fix(footer): point Shard Status link to /site/shard' (#90) from fix/footer-shard-status-link into main
All checks were successful
Build container images / build (push) Successful in 2m21s
SonarQube / analysis (push) Successful in 2m27s
Build container images / deploy (push) Successful in 37s
Reviewed-on: #90
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 19:10:48 +00:00
dc90df9fff fix(footer): point Shard Status link to /site/shard
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 38s
The footer's "Shard Status" link targeted /site/status; point it at the
richer live shard page at /site/shard.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 14:09:47 -05:00
68126efc0b Merge pull request 'refactor(server): dedupe shard-state shaping, upsert builder, and config DB models' (#89) from refactor/dedupe-shardstate-config-db into main
All checks were successful
Build container images / build (push) Successful in 1m4s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 12m11s
Reviewed-on: #89
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 17:49:31 +00:00
401db8f75c refactor(server): dedupe shard-state shaping, upsert builder, and config DB models
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 11m4s
Address the SonarQube copy-paste findings that reflect real duplication (as
opposed to the intentional cross-package / admin-player mirror copies, which
are by-design and left as-is):

- shardState.model.js: listOnline() re-inlined the exact field mapping that
  shapeOnline() already provides (used by listOnlineLinked). Collapse it onto
  shapeOnline so the two can no longer drift.
- shardState.db.js: extract a single upsertRow(table, pkCol, pk, fields,
  {coalesce}) builder for the five near-identical INSERT ... ON DUPLICATE KEY
  UPDATE bodies (online/houses/champs/guilds/governors). shard_online keeps its
  COALESCE-on-NULL semantics via the coalesce flag.
- botConfig/emailConfig/uoLinkConfig .db.js: generate get()/upsert() from a
  shared singletonConfigDb(table, cols) factory instead of three byte-identical
  copies.

Behavior unchanged; full server suite (381 tests) passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 12:35:56 -05:00
9b0f2d93d8 Merge pull request 'chore(quality): resolve SonarQube code smells across website' (#88) from chore/sonar-code-smells into main
All checks were successful
Build container images / build (push) Successful in 1m19s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 2m29s
Reviewed-on: #88
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 14:27:56 +00:00
12d50fd615 chore(quality): resolve SonarQube code smells across website
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.

By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
  drops under the threshold — shard model upsert builders, page/wiki update,
  block validation, notification stream mapping (dispatch table), SSO mobile
  login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
  command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
  CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
  a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
  components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
  (two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D.  S125 (5): reword state-shape comments that
  parsed as code.  S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
  prefix list): assorted one-liners.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 04:35:39 -05:00
4993470fa2 Merge pull request 'ci(sonarqube): populate the "Unit Tests" measure via a test-execution report' (#87) from ci/sonar-test-execution-report into main
All checks were successful
Build container images / build (push) Successful in 59s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m28s
Reviewed-on: #87
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 06:26:40 +00:00
2f3e8f7df5 ci(sonarqube): report test execution so the Unit Tests measure populates
All checks were successful
PR Checks / bot-install (pull_request) Successful in 14s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 9m25s
The lcov reports only feed SonarQube's Coverage metric — the "Unit Tests" tile
stayed "-" because we never provided a test-execution report (a separate input
via sonar.testExecutionReportPaths, in SonarQube's own Generic Test Execution
XML format, which the lcov/junit reporters don't produce).

Add a dependency-free custom node:test reporter (scripts/sonar-test-reporter.mjs)
that emits that XML — repo-root-relative <file path> entries matching sonar.tests,
integer-ms durations — and wire it into both the server and client coverage runs
in sonarqube.yml, plus sonar.testExecutionReportPaths in sonar-project.properties.

Verified locally: server 380 + client 43 test cases, well-formed XML, all three
reporters (spec/lcov/sonar) coexist in one `node --test` invocation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 01:16:08 -05:00
68a4e9295b Merge pull request 'test: meaningful unit tests for server models/controllers + client logic' (#86) from test/coverage-meaningful-gaps into main
All checks were successful
Build container images / build (push) Successful in 1m3s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m32s
Reviewed-on: #86
2026-07-21 05:55:46 +00:00
886a504152 test(client): unit-test the pure-logic modules + wire coverage into CI/Sonar
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 9m36s
Stand up a client test suite on Node's built-in runner (no vitest/jsdom — the
targeted modules are plain ESM with no browser/DOM deps) and cover the
meaningful client logic, not presentational components:

- api/client.js: the fetch wrapper — always sends the session cookie, maps a
  non-2xx response to a thrown ApiError (body.message → statusText fallback),
  resolves an empty body to null, sets Content-Type for JSON but NOT for raw
  FormData uploads, and builds/encodes query strings + path params.
- lib/shardEvents.js: describe() across event kinds (payload vs live frame,
  actor name→acct→"Someone" fallback, sale pluralization, champ.update
  branches) and the categoryOf table-consistency check.
- data/regionBuckets.js: the presence roll-up, incl. the first-match-wins
  ordering and the "buckets always reconcile to the total" invariant.
- lib/heroLayout.js: parseLayout's version/shape guard and heroBackground's
  default-vs-custom branch.
- lib/format.js: the date/label formatters + relative-time buckets.

Wiring so these actually count:
- client/package.json gains a `test` script (node --test);
- pr-checks.yml runs the client tests as a PR gate;
- sonarqube.yml generates a client LCOV report and sonar-project.properties
  feeds it alongside the server report (SF paths resolve to client/src/...).

43 client tests; coverage on the tested modules: format/regionBuckets/
heroLayout 100%, api client 86%, shardEvents 80%.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:38:53 -05:00
35e5269ec5 test(server): unit-test auth, invite, password-reset, and public controllers
Add controller-level unit tests (mock req/res, monkeypatched collaborators)
focused on security boundaries and decision logic the API must not regress:

- auth.controller: honeypot handling, non-enumerating generic-fail for every
  credential failure, inactive-account refusal, the TOTP challenge branch that
  must NOT issue a session, register-mode gating + dup-username 409, and logout
  that always clears the cookie and revokes the session (even on error).
- invite.controller: user created at the invite's PRESET role, and the lost
  double-accept race rolling back the just-created user.
- passwordReset.controller: identical generic 200 whether or not the email
  matched (incl. internal errors), per-account mail-failure isolation, the
  single-use consume race, and revoke-everywhere-on-reset with no auto-login.
- public.controller: staff-only draft visibility, token-gated page preview,
  wiki search precedence + unknown-filter handling, contact 502.
- shard.controller (public): the PUBLIC_KINDS feed allowlist and the public
  house view stripping owner/price — both leak-prevention boundaries.

Lifts: auth.controller 46%→94%, passwordReset 33%→93%,
public.controller 28%→65%, shard.controller 45%→68% line coverage;
server aggregate 63.5%→70.4%.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:33:01 -05:00
99fd9acddb test(server): unit-test pages, shardState, and moderation model logic
Add meaningful unit tests for three server models with untested business
logic, each against an in-memory fake db (no DB required):

- pages.model: slug validation + reserved-name guard, slug immutability,
  the protected ON-via-update / OFF-only-via-unprotect asymmetry, publish
  stamping, draft invisibility to public reads, dup-slug → 409, block gate.
- shardState.model: partial-refresh field dropping (vitals must not clobber
  login fields), is_idoc derivation, economy clamp/ordering/Number coercion,
  presence zero-snapshot defaults, payload-fallback shaping, and the
  camelCase read-shaping contract the site + Android client depend on.
- moderation.model: five-feed window merge, userSummary count/total
  semantics (total sums unknown types too), and graceful degradation when
  bot config is missing.

Lifts: pages.model 23%→82%, moderation.model 27%→85%,
shardState.model 33%→62% line coverage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:26:01 -05:00
b474303052 Merge pull request 'ci(sonarqube): generate and report server test coverage' (#85) from ci/sonar-test-coverage into main
All checks were successful
Build container images / build (push) Successful in 2m54s
SonarQube / analysis (push) Successful in 2m55s
Build container images / deploy (push) Successful in 47s
Reviewed-on: #85
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:57:06 +00:00
e515fc0c9d ci(sonarqube): generate and report server test coverage
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 44s
PR Checks / client-build (pull_request) Successful in 9m24s
SonarQube reported 0% coverage because the analysis workflow never ran
the test suite — the scanner does static analysis only and was handed no
coverage report, and sonar-project.properties defined no report path.

Generate an LCOV report in sonarqube.yml before the scan using Node's
built-in test coverage (run from the repo root so SF: paths are
server/src/... and resolve against the project base dir), and point
the scanner at it via sonar.javascript.lcov.reportPaths. Node's lcov
coverage reporter needs Node >= 22, so the coverage job pins node 22.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:46:44 -05:00
c73273f1bc Merge pull request 'fix(security): add SPA CSP, drop x-powered-by, strengthen dedupe hash' (#84) from fix/security-headers-csp into main
All checks were successful
Build container images / build (push) Successful in 1m31s
Build container images / deploy (push) Successful in 38s
SonarQube / analysis (push) Successful in 2m15s
Reviewed-on: #84
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:13:41 +00:00
5e5e0d7a91 docs(readme): add SonarQube project badges
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 9m23s
PR Checks / server-tests (pull_request) Successful in 10m10s
Bugs, code smells, duplicated lines, LOC, security hotspots, security rating,
and vulnerabilities badges linking to the project dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 23:02:11 -05:00
e1461d9161 fix(security): add SPA CSP, drop x-powered-by, strengthen dedupe hash
Address SonarQube security hotspots on the website:

- server/src/app.js: replace `contentSecurityPolicy: false` with a helmet CSP
  tuned for the built React SPA (script-src 'self'; style-src adds 'unsafe-inline'
  for React inline styles + the Google Fonts stylesheet; font-src gstatic; img-src
  allows data:/https: for uploads, embedded body images and BRAND_* assets;
  connect-src 'self' for REST+SSE). upgrade-insecure-requests is intentionally
  omitted (TLS terminates at the proxy; keeps local `npm start` over http working).
  The /api/docs Swagger UI route gets a scoped looser policy (inline script/style)
  since swagger-ui-express injects an inline bootstrap.
- client/vite.config.js: disable the inline module-preload polyfill so code-split
  builds keep `script-src 'self'` valid (RichTextEditor is a separate chunk).
- bot/src/app.js, server/src/internalApp.js: disable x-powered-by on the two
  internal-only listeners (the public app already strips it via helmet).
- shardEvents dedupe key: SHA-1 -> SHA-256 truncated to 40 hex chars (fits the
  existing CHAR(40) column, no migration; it is a content fingerprint, not a
  security value). schema.sql comment updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 23:02:11 -05:00
82bf2c972c Merge pull request 'ci(sonarqube): non-blocking SonarQube analysis on push to main' (#83) from ci/sonarqube-analysis into main
All checks were successful
Build container images / build (push) Successful in 1m18s
Build container images / deploy (push) Successful in 36s
SonarQube / analysis (push) Successful in 2m13s
Reviewed-on: #83
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 02:54:14 +00:00
c54bb54834 ci(sonarqube): add non-blocking SonarQube analysis on push to main
All checks were successful
PR Checks / bot-install (pull_request) Successful in 1m16s
PR Checks / server-tests (pull_request) Successful in 9m41s
PR Checks / client-build (pull_request) Successful in 10m39s
Wire the self-hosted SonarQube server into Gitea via a Gitea Actions
workflow. Runs on push to `main` (post-merge) and workflow_dispatch, so
it feeds the dashboard without gating any PR. Adds sonar-project.properties
(project key runic-gateway-website; server/client/bot sources, server tests,
node_modules/dist/generated excluded).

Requires two one-time Gitea settings: secret SONAR_TOKEN and variable
SONAR_HOST_URL. The scan does not wait on the Quality Gate, keeping it
fully non-blocking.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 21:43:00 -05:00
86420661b5 Merge pull request 'fix(db): strip inline -- comments before splitting schema statements' (#82) from fix/schema-loader-inline-comment-split into main
All checks were successful
Build container images / build (push) Successful in 1m19s
Build container images / deploy (push) Successful in 45s
Reviewed-on: #82
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 00:31:13 +00:00
d1b3351360 fix(db): strip inline -- comments before splitting schema statements
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m18s
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / bot-install (pull_request) Successful in 9m26s
The schema loader stripped only full-line -- comments, then split the
file on ';'. A trailing comment containing a semicolon (e.g. the
mobile_auth_sessions.session_id column: `-- uuid; carried inside...`)
chopped the CREATE TABLE in half, so MariaDB got the fragment and failed
with `error ... near '' at line 3`, crash-looping the server on boot.

Strip -- comments on every line (full-line and trailing) before the ';'
split. Safe because the schema never places -- inside a string literal.

Verified by running ensureSchema() against a fresh MariaDB: all 49 tables
create cleanly and mobile_auth_sessions has all 11 columns.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 19:10:53 -05:00
dacc1bd4f7 Merge pull request 'feat(mobile-sso): serve assetlinks.json + App Links redirect allowlist' (#81) from feat/mobile-app-links into main
All checks were successful
Build container images / build (push) Successful in 1m7s
Build container images / deploy (push) Successful in 34s
Reviewed-on: #81
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 23:57:53 +00:00
bcc96e7cfb feat(mobile-sso): serve assetlinks.json + App Links redirect allowlist
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m23s
PR Checks / client-build (pull_request) Successful in 10m6s
PR Checks / bot-install (pull_request) Successful in 9m16s
Add the server side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md):

- GET /.well-known/assetlinks.json at the web root, gated by the new admin
  setting `mobile_app_links_enabled` (default off -> 404; on-but-no-fingerprint
  -> 404). Emits the Digital Asset Links statement for the fixed published
  package (MOBILE_APP_PACKAGE) + MOBILE_APP_CERT_SHA256 fingerprint(s).
- mobileSso `/start` additionally accepts this shard's own self-origin
  https://<host>/mobile/callback when App Links are enabled — one additive
  exact-match entry, derived from APP_BASE_URL/request origin, never client
  input; the custom-scheme allowlist is never narrowed. The settings lookup is
  short-circuited for non-https redirects so custom-scheme rejections stay fast.
- settings.isMobileAppLinksEnabled() (fail-closed) + getPublic().mobileAppLinks;
  admin updateSettings validates the boolean; seed default off.

Tests: test/appLinks.test.js (route gating + allowlist). Full suite 284 pass.
Swagger unchanged (web-root verification file is #swagger.ignore'd).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 18:37:17 -05:00
d37c3a46a9 Merge pull request 'feat(auth): native SSO authorization bridge for the Android app (M9 Part 1)' (#80) from feature/mobile-sso-bridge into main
All checks were successful
Build container images / build (push) Successful in 53s
Build container images / deploy (push) Successful in 35s
Reviewed-on: #80
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 22:27:22 +00:00
e3dd5358b6 feat(auth): Active Devices — view/revoke mobile sessions
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m16s
PR Checks / bot-install (pull_request) Successful in 9m17s
Adds the self-service device-session surface the mobile-SSO spec requires, on
top of the existing mobile_refresh_tokens store.

- Schema: device_name + last_used_at columns on mobile_refresh_tokens (nullable,
  additive via the ALTER section; seeded to now on insert). With single-use
  rotation each login/refresh inserts a fresh row, so the active row's timestamp
  is the session's last activity, and the label is carried forward on refresh.
- Model: listActiveForUser (one row per live device, no token hash) +
  revokeByIdForUser (ownership-scoped, idempotent).
- GET /auth/me/sessions + DELETE /auth/me/sessions/:id (role-agnostic, behind
  requireAuth). Named distinctly from /auth/me/devices (push endpoints).
- device_name is an optional field on /auth/mobile/login and
  /auth/mobile/sso/exchange so the app can label a device.
- Client: an "Active Devices" panel on the player account page (list + sign a
  device out), plus the PlayerLogin change to honor the mobile SSO bridge's
  { redirect } deep link on a 2FA completion.
- Swagger DeviceSession schema + regenerated spec; 3 controller tests. Full
  server suite green (274); client builds.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 17:01:47 -05:00
61f4591a6b feat(auth): native SSO authorization bridge for the Android app
Add a Mobile SSO Authorization Bridge so the native app can "Sign in with
Google/Discord" without shipping any OAuth secret. It EXTENDS the existing
/auth/sso/* redirect flow (same PKCE-vs-IdP, link-only + opt-in provisioning,
TOTP gate) and terminates in the existing mobile bearer tokens — not a parallel
auth path.

- Schema: mobile_auth_sessions + mobile_auth_codes (short-lived, self-pruning;
  authorization code stored hash-only, PKCE challenge is a hash by construction).
- GET /auth/mobile/sso/start: validate provider enabled + redirect_uri by EXACT
  allowlist match (never prefix), seed a bridge session, reuse the SSO redirect
  tagged mode:'mobile' (new redirectToIdp helper extracted from beginFlow).
- SSO callback + finishSsoTotp gain a mode:'mobile' branch: mint a single-use,
  hashed, PKCE-bound code and redirect to the fixed app callback (code + echoed
  state, never a token) instead of setting a cookie. 2FA keeps full parity via
  the existing web TOTP form (now carrying the bridge session).
- POST /auth/mobile/sso/exchange: verify Layer-B PKCE (before burning the code),
  single-use consume, then issue the SAME pair as /auth/mobile/login.
- Discovery reuses GET /auth/providers; refresh/logout reuse /auth/mobile/*.
- Rate limits: /start per-IP+provider, /exchange per-IP. Boot-time +
  opportunistic prune of both tables (no cron, mirrors revoked_sessions).
- Redirect allowlist is MOBILE_AUTH_REDIRECT_URIS (default the one fixed
  runicgateway://auth/callback); App Link URIs can be appended per shard later.
- Swagger regenerated; 39 tests (model single-use/gating + full controller
  matrix: bad/expired/reused code, PKCE mismatch, disabled provider, redirect
  allowlist, TOTP-through-bridge). Full suite green (271).

Refs docs/website/BACKEND_DESIGN.md, docs/android/PLAN.md §9 (M9).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 16:55:06 -05:00
31b72859ce Merge pull request 'feat(settings): surface push.ntfyUrl in /public/settings for the app' (#79) from feat/settings-push-ntfy-url into main
All checks were successful
Build container images / build (push) Successful in 1m2s
Build container images / deploy (push) Successful in 45s
Reviewed-on: #79
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 20:55:56 +00:00
a789ee3ac9 feat(settings): surface push.ntfyUrl in /public/settings for the app
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m39s
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / bot-install (pull_request) Successful in 9m17s
The Android app's embedded push distributor (M7 Part 2) needs the shard's
client-facing ntfy relay URL to build its device topic endpoint, but the M7
Part 1 backend only used the NTFY_* vars server-side and never surfaced them.

Add a `push: { ntfyUrl }` block to settings.getPublic(), sourced from
NTFY_PUBLIC_URL or the first NTFY_ALLOWED_ORIGINS entry (never the possibly
internal NTFY_BASE_URL); null when unconfigured, so the app shows push as
unavailable for that shard. Additive, non-sensitive, forward-compatible.

- Extend the PublicSettings swagger schema; regenerate swagger-output.json.
- publicBrand.test.js: cover null / NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS.
- Document NTFY_PUBLIC_URL in .env.example and (docs PR) BACKEND_DESIGN.md.

Full server suite green (250 pass).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 15:25:40 -05:00
4fa73d3ccf Merge pull request 'feat(push): M7 backend — opt-in push notifications via self-hosted ntfy' (#78) from feat/push-notifications-backend into main
All checks were successful
Build container images / build (push) Successful in 1m15s
Build container images / deploy (push) Successful in 35s
Reviewed-on: #78
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 15:27:59 +00:00
416761f8f7 feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 9m21s
PR Checks / bot-install (pull_request) Successful in 9m17s
Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.

- Schema: push_devices (per-device endpoint) + notification_subscriptions
  (per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
  streams (news.post, server.status, idoc.warning, champ.start, governor.election)
  drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
  (vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
  only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
  to each subscribed device; never throws. Two producers — shardIngest.ingest
  (beside the SSE broadcast) and the create/publish-post path (news.post).
  Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
  be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
  enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
  /auth/me/devices, DELETE /auth/me/devices/:id, GET
  /auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
  Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
  no published host port, anonymous unguessable topics (no accounts) — zero
  interactive setup. No publish token required (content-free design); optional
  NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
  content-free payload) + notifications route auth gate. Full suite green (247).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 05:13:48 -05:00
030414f13d Merge pull request 'feat(public): version/health surfacing + typed brand block' (#77) from feat/public-version-and-brand into main
All checks were successful
Build container images / build (push) Successful in 1m2s
Build container images / deploy (push) Successful in 33s
Reviewed-on: #77
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 17:38:20 +00:00
c35509e8b3 feat(public): type the brand block so mobile clients get typed theming
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m29s
PR Checks / server-tests (pull_request) Successful in 10m30s
PR Checks / bot-install (pull_request) Successful in 9m21s
Branding is already returned by GET /public/settings (the `brand` block:
name/colors/logo/hero/favicon, per-shard from BRAND_*). §8.6 of the Android
plan asks to confirm it — this makes it a first-class part of the contract so
the app's OpenAPI codegen produces typed branding instead of an untyped map.

- Swagger: add Brand + PublicSettings schemas; /public/settings now references
  PublicSettings (was additionalProperties:true). Brand documents that asset
  fields may be site-relative paths (resolve against the base URL).
- test/publicBrand.test.js locks the brand theming contract the app depends on
  (all fields present; BRAND_* defaults; admin site_title/contact_email
  overrides; accentInt never leaked).

No behavior change to the response — it already carried `brand`; this types and
guards it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 11:58:01 -05:00
90c8eae20f feat(public): version/health surfacing for the app first-run probe
Expose a small backend identity/version descriptor (§8.4 of the Android plan)
so a client can positively recognize a Runic Gateway backend on first-run and
run a version-mismatch guard, instead of inferring from an incidental shape.

- New config/version.js: { service: 'runic-gateway', api: 'v1', server: <pkg> }.
- GET /public/status now includes a `version` block (the app already calls this
  on first-run, so it gets identity + version in one round trip).
- New GET /public/version: a lightweight, DB-free identity endpoint — the
  canonical target for the version guard and a cheap liveness check.
- Swagger: PublicVersion schema + version on PublicStatus; /version annotated.
- test/publicVersion.test.js covers the config shape and the DB-free 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 11:47:26 -05:00
93a2c0d55f Merge pull request 'feat(auth): role-agnostic self-service surface under /auth/me' (#76) from feat/auth-me-self-surface into main
All checks were successful
Build container images / build (push) Successful in 1m4s
Build container images / deploy (push) Successful in 34s
Reviewed-on: #76
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 16:34:40 +00:00
fc5255da99 feat(auth): role-agnostic self-service surface under /auth/me
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m23s
PR Checks / bot-install (pull_request) Successful in 9m19s
Add /auth/me/account* — the canonical "me" endpoints for every authenticated
role (Android app §6.4/§8.1). Reuses the existing account.controller handlers
(getAccount, changeUsername, changePassword, TOTP setup/enable/disable, list/
unlink identities) verbatim behind requireAuth (any role) — no logic
duplication. The app gets one self surface and never has to touch /admin; the
old /player/account/* and /admin/account/* routes stay for web back-compat.

New routes (all bearer- or cookie-auth, any active role):
- GET    /auth/me/account
- PATCH  /auth/me/account/username
- PATCH  /auth/me/account/password
- POST   /auth/me/account/totp/setup|enable|disable
- GET    /auth/me/account/identities
- DELETE /auth/me/account/identities/:provider

Mounted as a sub-router; the bare GET /auth/me is unchanged. Swagger
regenerated with #swagger annotations. Adds test/authMe.test.js (the group
gate rejects unauthenticated callers with 401).

Verified end-to-end against MariaDB: a player and an editor both drive the
same surface (role-agnostic), username/password changes work, a password
change revokes the caller's old bearer token, and validation/401 paths behave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 04:55:01 -05:00
715eaedd74 Merge pull request 'feat(auth): self-service password reset (backend + web)' (#75) from feat/password-reset into main
Some checks failed
Build container images / build (push) Successful in 1m14s
Build container images / deploy (push) Failing after 10m47s
Reviewed-on: #75
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 09:36:35 +00:00
250cb1e2d3 Merge branch 'main' into feat/password-reset
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m26s
PR Checks / bot-install (pull_request) Successful in 9m31s
2026-07-19 09:04:43 +00:00
10aed49bb6 feat(auth): self-service password reset (backend + web)
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m45s
PR Checks / server-tests (pull_request) Successful in 10m42s
PR Checks / bot-install (pull_request) Successful in 9m21s
Add a full password-reset flow — the prerequisite for the Android app
(docs/android/PLAN.md §8.2), which hands off to the website for reset
rather than shipping a native screen.

Backend:
- password_resets table: stores only the sha256 hash of an opaque 32-byte
  token (mirrors user_invites / mobile_refresh_tokens), single-use, ~1h TTL.
- model/passwordResets + users.getActiveByEmail (email is non-unique, so a
  request can match several accounts, each emailed its own link).
- mailer.sendPasswordReset (fails soft when email is unconfigured).
- Endpoints: POST /auth/password/forgot (always a generic 200 — no account
  enumeration), GET|POST /auth/password/reset/:token. Confirming rotates the
  hash and revokes every session (web cutoff + mobile refresh tokens); it does
  not auto-login, so a 2FA account still passes TOTP next sign-in. Also serves
  SSO-only accounts (null hash) as their set-initial-password path.
- Dedicated request/confirm rate limiters. Swagger regenerated.

Web:
- ForgotPassword + ResetPassword pages, routes /account/forgot and
  /account/reset/:token, and a "Forgot your password?" link on the login page.

Tests: test/passwordResets.test.js (5). All server tests pass; client builds;
end-to-end smoketest against MariaDB passes (no-enumeration, single-use, hash
rotation, session revoke, login with the new password).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 03:57:13 -05:00
896773b8a5 Merge pull request 'Moderation appeals (Phase 6c) + Discord auto-reversal (Phase 6d)' (#74) from feature/moderation-appeals into main
All checks were successful
Build container images / build (push) Successful in 1m32s
Build container images / deploy (push) Successful in 37s
Reviewed-on: #74
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 05:18:18 +00:00
60e6a60842 Merge branch 'main' into feature/moderation-appeals
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m35s
PR Checks / client-build (pull_request) Successful in 9m51s
PR Checks / bot-install (pull_request) Successful in 9m22s
2026-07-19 03:49:39 +00:00
9ac1f35fa0 Merge pull request 'docs: generalize deployment section to any reverse proxy' (#73) from docs/reverse-proxy-generic into main
All checks were successful
Build container images / build (push) Successful in 1m27s
Build container images / deploy (push) Successful in 36s
Reviewed-on: #73
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 03:20:01 +00:00
028ba8c5e4 feat(moderation): appeals (6c) + Discord reversal on approve (6d)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / bot-install (pull_request) Successful in 9m37s
Players whose linked Discord identity was banned or muted can now submit
an appeal from the portal and track it; staff get a queue in the admin
moderation section to claim and resolve (approve/deny) appeals. Approving
a ban/mute appeal best-effort asks the Discord bot to reverse the action
(unban / clear timeout) via the internal API and posts a mod-log embed; a
down bot never fails the resolution (reversal_status is recorded).

- Schema: new server-owned `appeals` table (no cross-owner FK to
  mod_actions; existence validated in app code).
- Server: model/appeals/* + player appeals controller (submit/mine/
  eligible/withdraw) and admin queue handlers (list/claim/resolve/
  per-user) under the existing admin+moderator gate; one-active-appeal
  enforced app-side; eligibility keyed on the caller's linked Discord id.
- 6d: bot POST /internal/mod-reverse (+ modLog.postReversal) and
  server botInternalClient.reverseModAction, wired into resolve().
- Client: admin Appeals queue + resolve modal, ModerationUser appeals
  tab, player Appeals page (submit/withdraw), nav + routes + api methods.
- Docs: swagger annotations + component schemas, regenerated output.
- Tests: appeals controller + pure suites (server npm test 224 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 22:01:06 -05:00
5f09ab1146 docs: generalize deployment section to any reverse proxy
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m25s
PR Checks / server-tests (pull_request) Successful in 10m10s
PR Checks / bot-install (pull_request) Successful in 9m21s
Rework the README's Pangolin-specific deployment section to cover any
reverse proxy (Nginx, Caddy, Traefik, Pangolin). Add TRUST_PROXY /
X-Forwarded-* guidance and Nginx + Caddy config snippets, keeping
Pangolin as one documented example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:00:01 -05:00
b0549f5845 Merge pull request 'fix(shard): restrict staff in-game location to admins/moderators' (#72) from fix/staff-location-visibility into main
All checks were successful
Build container images / build (push) Successful in 59s
Build container images / deploy (push) Successful in 38s
PR Checks / client-build (pull_request) Successful in 9m43s
PR Checks / server-tests (pull_request) Successful in 10m35s
PR Checks / bot-install (pull_request) Successful in 9m24s
Reviewed-on: #72
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 02:39:22 +00:00
aa2177715e fix(shard): restrict staff in-game location to admins/moderators
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m23s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m18s
The public "Staff online" list on the Shard page exposed each staff
member's in-game location (map + coordinates) to everyone, including
logged-in players and unauthenticated visitors.

Location is now privileged data:
- Server: getOnline inspects the caller's role via getUserFromRequest
  (the same non-rejecting helper siteMode uses on public routes) and
  only includes map/x/y/z for admin/moderator callers. For everyone
  else the fields are omitted from the JSON entirely, so they can't be
  read from the network tab. serial + name (online status) still shown.
- Client: Shard.jsx gates the location span on the viewer's role from
  useAuth() (same pattern as RoleGate); non-privileged viewers see who
  is online but no location field is rendered.

Tests: publicShardOnline.test.js covers admin + moderator (location
included), player + unauthenticated + editor (location omitted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 21:18:52 -05:00
3e6ecb959a Merge pull request 'chore: add open-source governance files (GPLv3 + contributing docs)' (#71) from chore/open-source-governance into main
All checks were successful
Build container images / build (push) Successful in 54s
Build container images / deploy (push) Successful in 34s
Reviewed-on: #71
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 00:31:03 +00:00
Claude
d441e92029 chore: add open-source governance files (GPLv3 + contributing docs)
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m32s
PR Checks / bot-install (pull_request) Successful in 9m20s
Add standard open-source project files:
- LICENSE.md — GNU GPL v3.0 or later (verbatim)
- CONTRIBUTING.md — setup, workflow, and required AI-usage disclosure
- CONTRIBUTORS.md — maintainers, contributors, AI-assistance policy
- CODE_OF_CONDUCT.md — Contributor Covenant 2.1
- SECURITY.md — private vulnerability reporting
- .gitea/ISSUE_TEMPLATE/* + PULL_REQUEST_TEMPLATE.md
- README: License section (Copyright (C) 2026 Runic Gateway)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 19:10:55 -05:00
5639117936 Merge pull request 'fix(brand): link "Runic Gateway" footer badge to Gitea org' (#70) from fix/branding-footer-link into main
All checks were successful
Build container images / build (push) Successful in 1m11s
Build container images / deploy (push) Successful in 33s
Reviewed-on: #70
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 23:36:46 +00:00
50133155d6 fix(brand): link "Runic Gateway" footer badge to Gitea org
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m16s
PR Checks / bot-install (pull_request) Successful in 9m19s
Wrap the "Powered by Runic Gateway" wordmark in an anchor pointing to
https://gitea.whitlocktech.com/RunicGateway (new tab, noopener). Adds a
subtle accent-color hover on the wordmark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 18:16:51 -05:00
bdce23f9b6 Merge pull request 'feat(brand): default emblem — favicon, hero medallion, footer credit' (#69) from feature/branding into main
All checks were successful
Build container images / build (push) Successful in 1m57s
Build container images / deploy (push) Successful in 2m3s
Reviewed-on: #69
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 22:20:27 +00:00
a0a70ce2ca docs: rename project-structure root label UOMSITE/ → website/
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m48s
PR Checks / client-build (pull_request) Successful in 10m48s
PR Checks / bot-install (pull_request) Successful in 9m21s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 17:00:04 -05:00
526160721a feat(brand): emblem behind hero text + Powered By footer with logo
Address review: the SVG footer badge rendered too small, and the default
hero should feature the Runic Gateway emblem rather than the moon.

- Footer: drop powered-by.svg; render the emblem PNG beside a "Powered by
  Runic Gateway" label (left-justified, info text stays centered).
- Default hero: brand.hero (and the client fallbacks) now default to the
  emblem PNG. The untouched default hero centers the square emblem behind
  the text as a medallion with a symmetric legibility overlay (per-layer
  background-size so the overlay stays full-bleed). BRAND_HERO still
  overrides.
- Admin login / player shell / maintenance backgrounds center the emblem
  as a capped medallion instead of a cropped full-bleed cover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 16:56:22 -05:00
352ae4f256 feat(brand): default favicon emblem + Powered By footer badge
Ship the Runic Gateway emblem as the baked-in default favicon so an
instance renders a tab icon with no BRAND_FAVICON set, and place a
left-justified "Powered By" badge in the site footer.

- brand.favicon now defaults to /assets/img/favicon.ico (was empty).
  BRAND_FAVICON still overrides per-instance.
- Add favicon.ico + powered-by.svg under client/public/assets/img.
- SiteFooter: badge pinned to the left of the centered content column
  (absolute on >=641px, stacked on mobile); info text stays centered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 16:40:09 -05:00
a16092f13a Merge pull request 'feat(brand): BRAND_* env scheme — instance branding without a rebuild' (#68) from feature/brand-env into main
All checks were successful
Build container images / build (push) Successful in 2m13s
Build container images / deploy (push) Successful in 24s
Reviewed-on: #68
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 07:40:04 +00:00
7a08546da6 feat(brand): BRAND_* env scheme — instance branding without a rebuild
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m20s
Replace baked-in UOM/MysticMoon/UOMysticmoon branding with a BRAND_* env
scheme so one prebuilt image runs as any shard; UOMysticmoon becomes the
first tenant that sets these vars rather than a special case in the code.

Architecture (chosen because the app ships as a prebuilt image):
- server/src/config/brand.js + bot/src/brand.js read BRAND_* once at boot,
  with Runic Gateway defaults.
- Text/colors reach the SPA at RUNTIME through the existing public settings
  API (settings.model.getPublic -> SiteContext), so no client rebuild. The
  admin-editable site title + contact email still override BRAND_NAME/email.
- SiteContext applies BRAND_ACCENT_COLOR to the --accent CSS var at runtime.
- Express templates the built index.html <title>/description/OG/favicon at
  serve time from BRAND_* (renderIndexHtml in app.js).
- Server-side consumers read brand directly: emails, TOTP issuer, API docs,
  boot logs, HTML error page. Bot uses it for embed color + logs.

Assets: logo/hero/favicon delivered from a ./brand:/app/brand bind-mount
(BRAND_LOGO/HERO/FAVICON), with neutral defaults baked in; hero falls back
to a built-in image when unset.

Scope: also genericized package.json names (uomysticmoon-* -> runic-gateway-*)
and the DB_NAME/DB_USER/COOKIE_NAME code defaults (runic_gateway/runic/
rg_token). Production keeps its real values by pinning them in .env — see
.env.uomysticmoon.example, which reproduces the exact UOMysticmoon identity
(proof the substitution works). Changing a deployed COOKIE_NAME invalidates
existing sessions, so UOMysticmoon pins uomm_token.

Verified: 193 server tests pass, client builds, app.js loads + templates the
built index.html, brand transform injects title/description/OG/favicon.
2026-07-18 02:20:04 -05:00
1bb9e3c3c3 Merge pull request 'docs: move design docs to RunicGateway/docs' (#67) from chore/extract-docs into main
Some checks failed
Build container images / build (push) Failing after 28s
Build container images / deploy (push) Has been skipped
Reviewed-on: #67
2026-07-18 05:51:30 +00:00
b8f67fb208 Merge branch 'main' into chore/extract-docs
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / server-tests (pull_request) Successful in 9m55s
PR Checks / bot-install (pull_request) Successful in 9m19s
2026-07-18 05:27:59 +00:00
e0fadbdcc2 Merge pull request 'chore(org): retarget org paths to RunicGateway' (#66) from chore/org-rename-runicgateway into main
All checks were successful
Build container images / build (push) Successful in 1m14s
Build container images / deploy (push) Successful in 26s
Reviewed-on: #66
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 05:06:43 +00:00
b3033909d3 docs: move design docs to RunicGateway/docs
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m49s
PR Checks / client-build (pull_request) Successful in 9m28s
PR Checks / bot-install (pull_request) Successful in 9m30s
Extracted BACKEND_DESIGN.md, HERO_EDITOR.md, and WIKI_UPGRADE.md into the
central RunicGateway/docs repo (under docs website/, full history preserved
via git filter-repo). Repoint the README's two BACKEND_DESIGN.md links at
the new location.

Docs repo: https://gitea.whitlocktech.com/RunicGateway/docs
2026-07-18 00:05:51 -05:00
6c967d9a6c chore(org): retarget org paths to RunicGateway
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m30s
PR Checks / server-tests (pull_request) Successful in 10m34s
PR Checks / bot-install (pull_request) Successful in 9m21s
Repo was transferred UOM -> RunicGateway. build-images.yml already
derives its registry owner from github.repository_owner, so it now
publishes to gitea.whitlocktech.com/runicgateway/*, but docker-compose
still pulled from /uom/*. Point the app+bot images at the new owner so
deploys pull the images CI actually publishes. Also update the two
README links to the uo-link repo (UOM/link -> RunicGateway/link).

No branding text touched (that is handled separately).
2026-07-17 23:46:36 -05:00
ee085496ab Merge pull request 'Protocol 2.0/2.1 uo-link integration — boards, cross-links, news gump, account provisioning' (#65) from feature/protocol2-integration into main
All checks were successful
Build container images / build (push) Successful in 1m35s
Build container images / deploy (push) Successful in 35s
Reviewed-on: UOM/website#65
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 02:34:25 +00:00
3ef1c8e438 feat(provisioning): admin game-signup mode setting, invite link option, staff self-create
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 10m18s
PR Checks / bot-install (pull_request) Successful in 9m22s
Follow-ups from live testing:

- Game-account creation is now an admin Settings control (disabled / website /
  hybrid / game) instead of a hidden on/off flag. The site offers creation for
  website+hybrid; help text notes the shard's SignupMode (Bridge.cfg) has the final
  say. game_account_signup setting widened to a 4-value enum + validated on save.
- Invites: the accept link is ALWAYS returned and shown with a Copy button, and a
  "Email the invitation" toggle lets an admin create a link-only invite (no email)
  or email it. Backend takes sendEmail (default true) and always returns acceptUrl.
- Staff can create a game account from their own /admin/characters page too
  (POST /admin/shard/account → the shared createGameAccount controller), so the
  form is reachable in both the player and admin portals.

Note: the admin Houses view (/admin/houses) already worked; the earlier failure
was a stale Vite HMR state for the new route (needs a hard refresh).

Client build clean; server routes load; swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:08:59 -05:00
1629796235 feat(houses): tier house visibility — public IDOC-only, staff full, player own
Per request, split the single public house registry into three role-scoped views:

- Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/
  coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger";
  kept live via the public house.decay feed. The full-registry deltas
  (house.update / house.remove — which carry owner/price) are REMOVED from the
  public SSE allowlist so they never reach the public channel.
- Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS)
  backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay
  and search, kept live on the admin SSE channel.
- Player portal → "My houses" home-status section (own houses only, with decay/
  IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts.

Server tests green, client build clean, swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:50:37 -05:00
a165c90c62 fix(schema): remove semicolons from shard_governor_terms inline comments
ensureSchema() splits schema.sql on ';' and is not comment-aware, so the inline
comments "epoch ms; NULL = current" and "not in the feed; reserved" shattered the
CREATE TABLE into invalid fragments (ER_PARSE_ERROR on a fresh boot). Reworded both
to drop the semicolons. Caught during live-stack bring-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:25:14 -05:00
2976d5982f feat(provisioning): provisioning UI — signup, invites, accept page, unlink
Phase 6: the UI for the Phase 5 provisioning backend.

- CreateGameAccountForm: reusable game-account form (own username + password),
  mapping the sidecar errors (409/429/403/503) to friendly messages. Wired into
  GameAccounts (self-serve) — shown alongside the [link flow when the
  game_account_signup flag is on (exposed via public settings), so a registered
  player can create + link a game account from their portal.
- Admin Invites view (/admin/invites, admin-only): send an invite at a chosen
  access level, list invites with status, revoke pending ones. When email isn't
  configured the create response's accept link is surfaced to copy manually.
- Public accept page (/invite/:token): validates the invite, sets username +
  password (email + role pre-assigned), creates the account at that role and logs
  in; for a player invite it then offers the built-in "create game account" step
  before the portal. Honeypot-guarded like registration.
- Admin unlink wired into UserDetail via GameAccounts (per-account Unlink button,
  confirm + reconcile).
- Backend: expose gameAccountSignup availability in public settings.

Client build clean; server 193/193.

Refs .plans/protocol2-integration.md (Phase 6). Completes the Protocol 2.0/2.1 integration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:06:01 -05:00
91c206bf76 feat(provisioning): game-account signup, admin email invites, unlink (2.0)
Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.

- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
  shard (hashed there) and never stored/logged; the end-user browser IP is passed
  for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
  username + password) for the signed-in user and mirrors the link locally. Gated
  by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
  429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
  invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
  mailer.sendInvite (falls back to returning the accept link if email is off);
  public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
  user at the invite's preset role and logs them in, bypassing the registration
  gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
  mirror drop; account.unlinked ingest reconciles the mirror when a player runs
  [unlink in game. account.audit / account.unlinked are logged (admin channel
  only — never on the public SSE allowlist).

Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:50:49 -05:00
55a3adea99 feat(news): auto-push published news to the in-game Town Cryer News gump (2.1)
Phase 4: sync the site's published news posts into the Protocol 2.1 News gump.

- uoLinkClient.postNews / deleteNews.
- utils/newsGump.js — a STATE SYNC (not a one-shot announce leg): an article
  stays in the gump while its post is published news and is pulled when it leaves
  that state. buildArticle renders a compact gump-HTML block (centred title +
  plain-text excerpt — the gump supports only a small HTML subset) with a
  "more info" link to /site/news and an optional gump image from the
  `news_gump_image` setting. Every call is best-effort / never-throws.
- Hooked into the posts pipeline alongside the existing announce enqueue:
  syncPost on create/update/publish (fresh publish announces; edits refresh
  silently; leaving published-news pulls the article), removePost on delete.
- reassertAll() runs in uoLinkSocket.backfill on every WS (re)connect —
  reconciles the gump to our source of truth and recovers any article whose
  original live push failed (silent, so a reconnect never re-proclaims old news).

Server 185/185, swagger regenerated. Refs .plans/protocol2-integration.md (Phase 4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:40:47 -05:00
2957708bab feat(shard): Protocol 2.0 cross-links — titles, guild, governor, houses
Phase 3: surface the new board data on existing character/user pages.

- Character sheet: render the char.profile titles block (fame/karma + skill +
  selected reward title; numeric clilocs skipped since the site has no cliloc
  table yet), plus "Guildmaster" and "Governor of <city>" chips.
- Char profile enrichment (player/admin /shard/char/:serial, one shared path):
  attach guild + governorOf from our own boards. Guild is LEADERSHIP-ONLY — it's
  verifiable from current board state, whereas guessing membership from stale
  guild.join events risks showing a wrong guild, so we return null instead.
- Admin user detail (/admin/users/:id): new "Standing" section (governorships
  held + guilds led) via GET /users/:id/shard/standing; Houses rows now show the
  registry fields (decay level, placement price, co-owner/friend counts) already
  returned by listHousesForAccounts.

Server 179/179, client build clean, swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:45:15 -05:00
e9aa19a83d feat(shard): Protocol 2.0 boards UI — guilds, governors, houses, players-online
Phase 2: the public UI for the four new boards, following the ChampSpawns live
pattern (snapshot via useAsync + merge SSE deltas with useShardFeed).

- Players Online widget (components/PlayersOnline.jsx): total + region breakdown
  rolled up into display buckets (data/regionBuckets.js — the one place to retune
  the grouping); live via presence.online. Placed on the Shard page, replacing the
  static players-online stat tile.
- Guilds (/site/guilds): searchable board of rosters/alliances/leaders with a
  "recently joined" strip from guild.join.
- Governors (/site/governors): one card per city with a placeholder crest
  (data/cityCrests.js — swap for real art without touching components), election
  phase badge + autoPickAt countdown, and an on-demand "past governors" term
  history (the look-back reads the ledger captured in Phase 1). Clean empty state
  when City Loyalty isn't enabled.
- Houses (/site/houses): searchable registry with decay badges; price labelled
  "placement value", not a for-sale flag.
- API client methods + nav links (Guilds / Governors / Houses).

Client build clean (240 modules).

Refs .plans/protocol2-integration.md (Phase 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:33:57 -05:00
080478c4a1 feat(shard): ingest Protocol 2.0 boards — guilds, governors, presence, houses
Phase 1 of the Protocol 2.0/2.1 integration: the read/ingest backend for the four
new uo-link boards, following the established champs/pages pattern (ingest → our
MariaDB + snapshot-on-reconnect + public SSE + token-free public endpoint).

- Schema: shard_guilds, shard_governors, shard_governor_terms, shard_presence;
  extend shard_houses with the house.update registry columns (owner_name,
  co_owners, friends, price, decay, in_registry) so the decay-transition and
  registry feeds share one house row without clobbering each other.
- Ingest: route guild.update/remove, city.update, presence.online,
  house.update/remove; log guild.join (real-time joins feed); region.enter is
  broadcast-only. All new public kinds added to the SSE allowlist.
- Governor term history captured from day one: on every observed governor CHANGE
  the open term is closed and a new one opened, idempotent so backfill/duplicate
  city.update never spawn spurious terms. votes stays NULL (the feed carries only
  candidate count, not tallies) — we never fabricate vote numbers.
- Client + backfill: getGuilds/getGovernors/getHouses/getPresence; snapshot each
  board on every WS (re)connect, independently guarded so an empty/failed board
  (e.g. no City Loyalty) never wipes another.
- Public endpoints: /shard/{guilds,governors,governors/:city/history,presence,houses}.
- Tests: ingest routing for all new kinds + governor term-capture idempotency
  (15 new; full suite 179/179). Swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:28:54 -05:00
3b333b1b49 Merge pull request 'ci(deploy): correct deploy runner label to uom-deploy-runner' (#64) from ci/fix-deploy-runner-label into main
All checks were successful
Build container images / build (push) Successful in 2m24s
Build container images / deploy (push) Successful in 43s
Reviewed-on: UOM/website#64
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-17 10:52:00 +00:00
0facdb2b2a ci(deploy): correct deploy runner label to uom-deploy-runner
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m29s
PR Checks / client-build (pull_request) Successful in 9m57s
PR Checks / bot-install (pull_request) Successful in 9m23s
The deploy job referenced a runner labelled `uom_deploy`, which doesn't
match the actual self-hosted runner (`uom-deploy-runner`), so the job
would queue forever waiting for a runner that never picks it up. Update
the `runs-on` label (and the two doc comments) to the real label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 05:32:02 -05:00
49ad6891cf Merge pull request 'ci(deploy): auto-deploy prod stack after image build on merge to main' (#63) from ci/auto-deploy-on-merge into main
Some checks failed
Build container images / deploy (push) Has been cancelled
Build container images / build (push) Has been cancelled
Reviewed-on: UOM/website#63
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-17 10:29:04 +00:00
97d95052db ci(deploy): auto-deploy prod stack after image build on merge to main
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / bot-install (pull_request) Successful in 9m39s
PR Checks / client-build (pull_request) Successful in 13m0s
Add a `deploy` job to build-images.yml that runs on the self-hosted
`uom_deploy` runner and, via `needs: build`, fires only after a clean
image build+push. It pulls the fresh :latest images and recreates the
stack (pull → down → up -d) from /home/perry/website. Guarded on
refs/heads/main so a workflow_dispatch off another branch can't deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 05:03:01 -05:00
e744723db2 Merge pull request 'fix(bot): retry boot-time config fetch so bot self-heals on cold start' (#62) from fix/bot-boot-config-retry into main
All checks were successful
Build container images / build (push) Successful in 1m43s
Reviewed-on: UOM/website#62
2026-07-15 19:17:22 +00:00
fc2554e5c3 fix(bot): retry boot-time config fetch so bot self-heals on cold start
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m25s
PR Checks / client-build (pull_request) Successful in 9m53s
PR Checks / bot-install (pull_request) Successful in 10m0s
On `docker compose up`/restart the bot and app start together. The bot's
`depends_on: app` uses `condition: service_started`, which only waits for the
app container to launch — not for its internal server (3001) to be listening
after it reaches the DB and boots Express. bootstrap.js did a single un-retried
fetch, lost that race, gave up, and left the bot disconnected while the DB
`enabled` flag stayed true — so the admin panel showed "enabled but
disconnected" until an admin toggled off/on to force a pushConfig.

Retry the boot config fetch with backoff (~1 min, 2s apart) until the app
answers: retry on network errors and 5xx, bail on 4xx (a real misconfig, not a
startup race). Also wrap discordManager.start in try/catch so a bad-token boot
logs and keeps the process alive instead of crashing it via server.js's
exit-on-start-failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-15 09:39:19 -05:00
70122f3626 Merge pull request 'fix(public): always show real hero + drop nav from landing page' (#61) from fix/hero-stale-gate-and-nav into main
All checks were successful
Build container images / build (push) Successful in 1m24s
Reviewed-on: UOM/website#61
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-15 11:03:51 +00:00
64da0067f1 Merge branch 'main' into fix/hero-stale-gate-and-nav
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m4s
PR Checks / client-build (pull_request) Successful in 9m41s
PR Checks / bot-install (pull_request) Successful in 9m37s
2026-07-15 03:59:42 +00:00
5b6b63e1bc fix(public): always show real hero; drop nav from landing page
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m43s
PR Checks / client-build (pull_request) Successful in 9m39s
PR Checks / bot-install (pull_request) Successful in 9m40s
Bug 1 — Logged-out visitors saw the coming-soon Maintenance page while
admins saw the real hero. That difference is produced client-side by
MaintenanceGate (site_mode=maintenance && no user). Pull the `/` hero
route out from behind the gate so every visitor always lands on the real
Portal hero; the MaintenanceGate stays on all other public routes, so
content pages remain gated during maintenance and admins still preview
through it.

Bug 2 — The landing hero rendered the site nav because Portal used
PublicLayout with the default header=true. Pass header={false} so the
hero has no top nav (footer retained), using the layout's existing
escape hatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 22:57:49 -05:00
01b3bb52bf Merge pull request 'feat(shard): admin write plane, help-page queue, and public champion board' (#58) from feature/shard-admin-champs into main
All checks were successful
Build container images / build (push) Successful in 1m30s
Reviewed-on: UOM/website#58
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-14 18:46:50 +00:00
c31553aeb6 feat(shard): admin write plane, help-page queue, and public champion board
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / client-build (pull_request) Successful in 9m49s
PR Checks / bot-install (pull_request) Successful in 9m33s
Wire up the three uo-link sidecar surfaces that weren't integrated yet.

Champion spawns
- Ingest champ.update/champ.remove into a new shard_champs table (served from
  our own store, like online/houses); public /site/champs board with a nav link,
  live via the existing SSE feed (champ.* added to the public allowlist).

Staff write plane (admin + moderator)
- kick / ban / unban / broadcast via /admin/shard/*; actor is stamped server-side
  from the session, never the browser. Sidecar status codes mapped (403 disabled/
  protected, 404 unknown, 503/504 transient). admin.audit events are logged and
  surfaced at /admin/shard/audit.
- New admin "In-Game Ops" view (/admin/shard-ops), plus per-account Kick/Ban/Unban
  on the user-detail and character views (ShardAccountActions, self-gated to staff).

Help-page (support) queue
- Ingest page.new/updated/closed into a new shard_pages table; respond/close via
  /admin/shard/pages/*. Champ board and page queue are snapshotted from the
  sidecar's /champs and /pages on every WS (re)connect (guarded so a failed call
  never wipes local state).

Verified live end-to-end against MariaDB + the Rust sidecar + ServUO; unit tests
cover ingest routing (shardIngest.champsPages.test.js). Swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 13:16:10 -05:00
2dc360ca48 Merge pull request 'ci: gate PRs into main on server tests + client build' (#57) from ci/pr-checks into main
All checks were successful
Build container images / build (push) Successful in 1m14s
Reviewed-on: UOM/website#57
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-12 15:40:17 +00:00
34c511c8d0 ci: gate PRs into main on server tests + client build
All checks were successful
PR Checks / server-tests (pull_request) Successful in 11m35s
PR Checks / client-build (pull_request) Successful in 9m42s
PR Checks / bot-install (pull_request) Successful in 9m34s
Add .gitea/workflows/pr-checks.yml running on pull_request into main.
Three parallel jobs on the existing ubuntu-latest runner:

  • server-tests  — npm ci + node --test (164 tests, no DB needed:
    the suite stubs models and points the pool at a dead port)
  • client-build  — npm ci + vite build
  • bot-install   — npm ci only (catches a broken/stale lockfile)

No ESLint exists in the repo yet, so no lint step. Complements
build-images.yml, which publishes images post-merge.

Enable in Branch Protection with status check pattern: PR Checks / *

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-12 10:07:16 -05:00
4fe90ea368 Merge pull request 'feat(admin): view a user's shard footprint at /admin/users/:id' (#56) from feature/admin-user-view into main
All checks were successful
Build container images / build (push) Successful in 1m48s
Reviewed-on: UOM/website#56
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-12 14:41:59 +00:00
ba4d758eab feat(admin): view a user's shard footprint at /admin/users/:id
Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.

Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
  so no new routes for roster/vendors/char

Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title

Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-12 09:36:43 -05:00
696d82f114 Merge pull request 'deploy: split build into docker-compose.dev.yml (prod compose pulls only)' (#55) from deploy/compose-dev-prod-split into main
All checks were successful
Build container images / build (push) Successful in 1m1s
Reviewed-on: UOM/website#55
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:45:41 +00:00
f4e7fc7e20 Merge branch 'main' into deploy/compose-dev-prod-split 2026-07-11 23:45:28 +00:00
6d4cd91bcc deploy: split build into docker-compose.dev.yml overlay
Make the base docker-compose.yml strictly production-shaped — image: only, no
build: — so a production host can only ever pull, never accidentally build
(compose gives build precedence for `up --build`/`build`, which mixed the two
modes). Local builds move to an explicit, non-auto-loaded overlay.

  Production:   docker compose pull && docker compose up -d
  Development:  docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build

Verified with `docker compose config`: base renders image-only (no build) for
both services; the dev overlay adds build back (app -> Dockerfile,
bot -> bot/Dockerfile). README quick-start updated to the two-file flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 18:42:05 -05:00
3628268dda Merge pull request 'deploy: pull prebuilt registry images in compose (IMAGE_TAG)' (#54) from deploy/compose-use-registry-images into main
All checks were successful
Build container images / build (push) Successful in 56s
Reviewed-on: UOM/website#54
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:36:55 +00:00
25ff5aa836 deploy: pull prebuilt registry images in compose (IMAGE_TAG)
Point the `app` and `bot` services at the images published to the Gitea
registry by the build-images workflow, so deploys pull instead of building:

  image: gitea.whitlocktech.com/uom/website-app:${IMAGE_TAG:-latest}
  image: gitea.whitlocktech.com/uom/website-bot:${IMAGE_TAG:-latest}

`build:` is kept, so `up --build` still works locally; the server runs
`docker compose pull && up -d`. IMAGE_TAG defaults to `latest` for routine
deploys and pins to an immutable `sha-<7>` build for reproducible deploys /
rollback — no per-deploy compose edits. Documented in .env.example + README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 18:34:31 -05:00
042a151358 Merge pull request 'ci: build & publish app + bot images to Gitea registry on merge' (#53) from ci/gitea-actions-image-build into main
All checks were successful
Build container images / build (push) Successful in 24s
Reviewed-on: UOM/website#53
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:01:04 +00:00
271 changed files with 34058 additions and 6663 deletions

View File

@@ -1,5 +1,14 @@
# ─── UOMysticmoon — root environment (used by docker-compose) ─── # ─── Runic Gateway — root environment (used by docker-compose) ───
# Copy to .env and fill in. NEVER commit the real .env. # Copy to .env and fill in. NEVER commit the real .env.
# To run this as an existing branded instance (e.g. UOMysticmoon), see
# .env.uomysticmoon.example for the exact BRAND_*/DB pinning to copy in.
# Container image tag pulled by docker-compose (app + bot). Published by the
# Gitea Actions workflow on every merge to main as `latest` and `sha-<7>`.
# Leave as `latest` for routine deploys; pin to a specific build for a
# reproducible deploy or rollback, e.g. IMAGE_TAG=sha-042a151.
# Deploy: `docker compose pull && docker compose up -d`.
IMAGE_TAG=latest
# App # App
NODE_ENV=production NODE_ENV=production
@@ -16,12 +25,32 @@ LOG_TO_FILE=true # set false for console-only
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs) LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
LOG_FILE=app.log LOG_FILE=app.log
# ─── Branding (BRAND_*) ───────────────────────────────────────────────────
# Instance identity. Defaults render as "Runic Gateway"; set these to rebrand
# without a rebuild. Text + colors reach the SPA through the settings API at
# runtime; the server templates index.html <title>/meta/OG/favicon at boot. The
# admin-editable "site title" and "contact email" settings, if set, override
# BRAND_NAME / BRAND_CONTACT_EMAIL.
BRAND_NAME=Runic Gateway
BRAND_SHORT_NAME=Runic Gateway
BRAND_TAGLINE=an independent private Ultima Online shard
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
BRAND_CONTACT_EMAIL=
BRAND_URL=
# Accent color — drives the web theme's --accent and the Discord embed color.
BRAND_ACCENT_COLOR=#7f99bd
# Image assets: paths under the /brand mount (see docker-compose.yml) or absolute
# URLs. Blank = built-in defaults (hero falls back to a neutral built-in image).
BRAND_LOGO=
BRAND_HERO=
BRAND_FAVICON=
# Database (the values here are shared by the `db`, `app`, and `bot` containers — # Database (the values here are shared by the `db`, `app`, and `bot` containers —
# the bot only ever touches its own tables: guild_config, mod_actions, warnings) # the bot only ever touches its own tables: guild_config, mod_actions, warnings)
DB_HOST=db DB_HOST=db
DB_PORT=3306 DB_PORT=3306
DB_NAME=uomysticmoon DB_NAME=runic_gateway
DB_USER=uomm DB_USER=runic
DB_PASSWORD=change-me-db-password DB_PASSWORD=change-me-db-password
DB_ROOT_PASSWORD=change-me-root-password DB_ROOT_PASSWORD=change-me-root-password
@@ -31,7 +60,8 @@ JWT_EXPIRES_IN=1d
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin). # auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS). # Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
COOKIE_SECURE=auto COOKIE_SECURE=auto
COOKIE_NAME=uomm_token # Changing this on a live instance invalidates existing sessions (users re-login).
COOKIE_NAME=rg_token
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban). # Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this # Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
@@ -44,8 +74,8 @@ TRUST_PROXY=1
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off. # request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
DEBUG_TRUST_PROXY=0 DEBUG_TRUST_PROXY=0
# Optional TOTP two-factor (opt-in per user). # Optional TOTP two-factor (opt-in per user). Defaults to BRAND_NAME when unset.
TOTP_ISSUER=UOMysticmoon # TOTP_ISSUER=Runic Gateway
TOTP_CHALLENGE_TTL=5m TOTP_CHALLENGE_TTL=5m
# First admin bootstrap — created only if no users exist yet. # First admin bootstrap — created only if no users exist yet.
@@ -88,3 +118,25 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string
UOLINK_BASE_URL=http://127.0.0.1:8080 UOLINK_BASE_URL=http://127.0.0.1:8080
UOLINK_WS_URL=ws://127.0.0.1:8080/ws UOLINK_WS_URL=ws://127.0.0.1:8080/ws
UOLINK_PROTOCOL=1 UOLINK_PROTOCOL=1
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
# for the Android app; docs/android/PLAN.md §11).
# NTFY_BASE_URL Public URL devices reach the relay at (behind the
# reverse proxy). Used BOTH to configure the ntfy service
# AND as the backend's SSRF allow-set — a device may only
# register an endpoint whose origin matches this.
# NTFY_ALLOWED_ORIGINS Optional, comma-separated extra allowed endpoint origins
# (defaults to NTFY_BASE_URL's origin). Set only if devices
# register endpoints on a different host than NTFY_BASE_URL.
# NTFY_PUBLISH_TOKEN Optional. The content-free-tickle design needs NO token;
# set one only to require auth on backend→ntfy publishes.
# NTFY_HOST_PORT Host port the ntfy container publishes :80 on (default
# 2586). The public reverse proxy forwards the notification
# subdomain to host:NTFY_HOST_PORT — required because the
# proxy lives outside the compose network and cannot reach
# ntfy any other way. Change only on a host-port conflict.
NTFY_BASE_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# NTFY_PUBLISH_TOKEN=
# NTFY_HOST_PORT=2586

30
.env.uomysticmoon.example Normal file
View File

@@ -0,0 +1,30 @@
# ─── UOMysticmoon instance — BRAND_* / identity overrides ───
#
# Runic Gateway's first "tenant". Copy these into the deploy .env (on top of
# .env.example) to run RunicGateway/website as UOMysticmoon. This is the proof
# that branding is data, not code: the same image renders as UOMysticmoon purely
# from these vars.
#
# Only the values that differ from the Runic Gateway defaults are shown.
# Identity
BRAND_NAME=UOMysticmoon
BRAND_SHORT_NAME=Mysticmoon
BRAND_TAGLINE=an independent private Ultima Online shard
BRAND_DESCRIPTION=UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
BRAND_CONTACT_EMAIL=UOMysticmoon@gmail.com
# BRAND_URL=https://<your public url>
# Visual — the existing UOM accent + hero image (baked into the image already).
BRAND_ACCENT_COLOR=#7f99bd
BRAND_HERO=/assets/img/uomysticmoon-main-hero.png
# TOTP label (defaults to BRAND_NAME, so optional — shown for clarity).
TOTP_ISSUER=UOMysticmoon
# ── Infrastructure identifiers — PIN to the existing production values so the
# ── app keeps talking to the same database and existing sessions stay valid.
# ── (These are NOT branding; they must match what production already uses.)
DB_NAME=uomysticmoon
DB_USER=uomm
COOKIE_NAME=uomm_token

View File

@@ -0,0 +1,41 @@
---
name: Bug report
about: Report something that is broken or behaving unexpectedly
title: "[bug] "
labels:
- bug
---
## Summary
<!-- A clear, concise description of the bug. -->
## Steps to reproduce
1.
2.
3.
## Expected behavior
<!-- What you expected to happen. -->
## Actual behavior
<!-- What actually happened. Include exact error messages and logs if you have them. -->
## Environment
- Component / repo:
- Version or commit:
- OS / runtime (Node, Rust, ServUO, browser…):
- Deployment (Docker Compose, local dev, bare metal…):
## Additional context
<!-- Screenshots, config (with secrets redacted), anything else that helps. -->
<!--
Security issue? Do NOT file it here. See SECURITY.md and email
whitlocktech@gmail.com instead.
-->

View File

@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Security vulnerability
url: https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/SECURITY.md
about: Please do not open a public issue for security problems — report them privately by email instead (see SECURITY.md).

View File

@@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest an idea, enhancement, or new capability
title: "[feature] "
labels:
- enhancement
---
## Problem / motivation
<!-- What are you trying to do? What's missing or painful today? -->
## Proposed solution
<!-- What you'd like to see happen. -->
## Alternatives considered
<!-- Other approaches you thought about, and why you prefer the one above. -->
## Additional context
<!-- Mockups, links, related issues, affected component/repo, etc. -->

View File

@@ -0,0 +1,33 @@
<!--
Thanks for contributing to Runic Gateway!
Please fill out the sections below and check every box before requesting review.
-->
## What & why
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
## How it was tested
<!-- Commands you ran, manual steps, screenshots. -->
## Checklist
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md).
- [ ] The change builds and existing tests/checks pass locally.
- [ ] I have added or updated tests/docs where it makes sense.
- [ ] My commits are reasonably scoped with clear messages.
## AI-assisted contributions (required)
This project **requires disclosure of AI tool usage**. Please pick one:
- [ ] No AI tools were used to produce this contribution.
- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand
every change, and take responsibility for it. AI-authored commits are
marked with a `Co-Authored-By` / `Assisted-By` trailer.
## License
- [ ] I agree that my contribution is licensed under this project's license
(**GNU GPL v3.0 or later**), and I have the right to contribute it.

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
Deterministic ordering: directories before files, each group sorted
case-insensitively with the raw name as a tiebreak. Output uses the classic
`tree(1)` box-drawing style so the result is stable across runs and platforms.
"""
import sys
def build(paths):
root = {}
for p in paths:
p = p.strip().replace("\\", "/")
if not p:
continue
node = root
for part in p.split("/"):
node = node.setdefault(part, {})
return root
def render(node, prefix, lines):
entries = list(node.items())
# directories (non-empty children dict) before files, then case-insensitive name
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
for i, (name, child) in enumerate(entries):
last = i == len(entries) - 1
branch = "└── " if last else "├── "
suffix = "/" if child else ""
lines.append(f"{prefix}{branch}{name}{suffix}")
if child:
render(child, prefix + (" " if last else ""), lines)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
except AttributeError:
pass
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
tree = build(sys.stdin.read().splitlines())
lines = [f"{root_label}/"]
render(tree, "", lines)
sys.stdout.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

View File

@@ -1,11 +1,21 @@
# Build and publish the app + bot container images to Gitea's container registry # Build the app + bot container images, publish them to Gitea's container
# on every merge to main. Production then pulls prebuilt images instead of # registry, then roll the production stack onto the fresh images — all on every
# building on the host. # merge to main. Production only ever pulls prebuilt images; it never builds.
#
# Two jobs run in sequence:
# build — builds & pushes website-app / website-bot images (on ubuntu-latest)
# deploy — `needs: build`, so it starts only after a clean build+push, and
# pulls + recreates the stack on the production host (on uom-deploy-runner)
# #
# Prerequisites (one-time): # Prerequisites (one-time):
# • An always-on Gitea runner with label `ubuntu-latest` whose jobs have the # • An always-on Gitea runner with label `ubuntu-latest` whose jobs have the
# host Docker socket mounted (/var/run/docker.sock), so `docker build` talks # host Docker socket mounted (/var/run/docker.sock), so `docker build` talks
# to the host daemon. This also gives free layer caching between runs. # to the host daemon. This also gives free layer caching between runs.
# • A second self-hosted runner labelled `uom-deploy-runner` ON the production host,
# with access to the Docker daemon and to /home/perry/website (the directory
# holding the production docker-compose.yml + .env). This is what actually
# rolls the stack; it must be able to `docker compose pull` from the registry
# (log in once on the host, or ensure the images are public-read).
# • Two repo secrets (Settings → Actions → Secrets): # • Two repo secrets (Settings → Actions → Secrets):
# REGISTRY_USER — the Gitea username that owns the token below # REGISTRY_USER — the Gitea username that owns the token below
# REGISTRY_TOKEN — a Gitea access token with `write:package` (+ read:package) # REGISTRY_TOKEN — a Gitea access token with `write:package` (+ read:package)
@@ -14,6 +24,7 @@
# Produces, in gitea.whitlocktech.com/<owner>/ : # Produces, in gitea.whitlocktech.com/<owner>/ :
# website-app:latest + website-app:sha-<7> # website-app:latest + website-app:sha-<7>
# website-bot:latest + website-bot:sha-<7> # website-bot:latest + website-bot:sha-<7>
# then deploys the `:latest` images (docker-compose.yml defaults IMAGE_TAG=latest).
name: Build container images name: Build container images
@@ -85,3 +96,25 @@ jobs:
- name: Log out (clear cached credentials from the runner) - name: Log out (clear cached credentials from the runner)
if: always() if: always()
run: docker logout "${REGISTRY}" || true run: docker logout "${REGISTRY}" || true
deploy:
# Roll production onto the images `build` just pushed. `needs: build` makes
# this wait for a clean build+push — if the build fails, deploy never fires,
# so the running stack is left untouched rather than torn down for nothing.
needs: build
runs-on: uom-deploy-runner
# Guard against a workflow_dispatch fired from a non-main branch: only ever
# deploy the main line to production.
if: github.ref == 'refs/heads/main'
steps:
- name: Pull the fresh images and recreate the stack
# `pull` grabs the new :latest images the build job published; `down`
# then `up -d` recreates the containers on them. Compose only recreates
# services whose image digest changed, so the DB stays put.
run: |
set -euo pipefail
cd /home/perry/website
docker compose pull
docker compose down
docker compose up -d
docker compose ps

View File

@@ -0,0 +1,80 @@
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
# build or failing test can't reach the deployable branch. Complements
# build-images.yml, which runs only AFTER merge (on push to main) to publish
# images — this one runs BEFORE merge.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
# • Enable Status Check
# • Status check patterns: PR Checks / *
# Note: Gitea only lists a context in its dropdown after it has reported once,
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
# without needing the dropdown.
#
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
# only Node (no Docker socket), and the server tests stub their models + point the
# DB pool at a dead port, so no MariaDB service is required.
name: PR Checks
on:
pull_request:
branches: [main]
# A newer push to the same PR cancels the in-flight run.
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
jobs:
server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install server deps
run: npm ci --prefix server
- name: Run server tests
run: npm test --prefix server
- name: Check the route manifest is current
# The URL surface is frozen while admin.routes.js is carved up by capability
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
# stack and diffing proves a "mechanical" refactor moved no URL. A PR that
# really does change one has to commit the new manifest, putting it in front
# of a reviewer instead of letting it pass silently.
run: npm run routes:manifest --prefix server -- --check
client-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install client deps
run: npm ci --prefix client
- name: Run client tests
# Pure-logic unit tests on Node's built-in runner (no browser/DOM).
run: npm test --prefix client
- name: Build client
run: npm run build --prefix client
bot-install:
# No tests/build to run; a clean install still catches a broken or
# out-of-sync lockfile before it ships in the bot image.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: bot/package-lock.json
- name: Install bot deps
run: npm ci --prefix bot

View File

@@ -0,0 +1,87 @@
# Run SonarQube static analysis against the code that just landed on `main` and
# report the results to the self-hosted SonarQube server for review. This is
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
# (which gates PRs) and build-images.yml (which ships images) — this one only
# feeds the dashboard.
#
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# runic-gateway-website project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
#
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
name: SonarQube
on:
push:
branches: [main]
# Allow re-running the analysis on demand from the Actions tab.
workflow_dispatch: {}
concurrency:
group: sonarqube-${{ github.ref }}
cancel-in-progress: true
jobs:
analysis:
runs-on: ubuntu-latest
steps:
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
# SonarQube uses git history to attribute issues to authors and to
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
# SonarQube runs static analysis only — it never executes the test suite,
# so we must produce a coverage report ourselves and hand it to the
# scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties).
# Node's built-in `lcov` coverage reporter needs Node >= 22.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install server deps
run: npm ci --prefix server
- name: Generate server test coverage (LCOV)
# Run from the repo root (not `--prefix server`) so the LCOV `SF:` paths
# are emitted as `server/src/...`, matching sonar.sources and letting the
# scanner resolve them against the project base dir. The server tests stub
# their models and point the DB pool at a dead port, so no MariaDB is needed.
run: |
mkdir -p server/coverage
node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
server/test/*.test.js
- name: Generate client test coverage (LCOV)
# The client's pure-logic modules (lib/, api/, data/) are plain ESM with no
# browser/DOM deps, so they run on the same built-in runner. Run from the
# repo root so the `SF:` paths come out as `client/src/...`. No `npm ci`:
# the tested modules import only relative files + Node built-ins.
run: |
mkdir -p client/coverage
node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
client/test/*.test.js
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

View File

@@ -0,0 +1,111 @@
name: sync-project-tree
# Keeps this repo's file-layout snapshot (docs/website/PROJECT_TREE.md in the
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
# tree from tracked files and, if it changed, opens (or force-updates) a pull
# request against the docs repo. It never writes to the docs repo's `main`
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
# other workflows use (the token needs repo read/write on RunicGateway/docs).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-project-tree
cancel-in-progress: true
env:
GITEA_HOST: gitea.whitlocktech.com
DOCS_REPO: RunicGateway/docs
SELF_REPO: RunicGateway/website
DOCS_PATH: website/PROJECT_TREE.md
TREE_TITLE: Website
ROOT_LABEL: website
PR_BRANCH: chore/sync-website-tree
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Check out this repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure python3 is available
run: |
set -euo pipefail
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
- name: Render PROJECT_TREE.md from tracked files
run: |
set -euo pipefail
mkdir -p _sync
{
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
printf '> by hand — changes will be overwritten by the next sync.\n\n'
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
printf 'git-ignored paths are excluded).\n\n'
printf '```text\n'
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
printf '```\n'
} > _sync/PROJECT_TREE.md
echo "----- generated ${DOCS_PATH} -----"
cat _sync/PROJECT_TREE.md
- name: Open or update the docs PR if the tree changed
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Secrets can carry a trailing CR/LF depending on how they were pasted;
# strip line breaks before they land in a URL or Authorization header.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
git clone --depth 1 "${REMOTE}" docs_repo
cd docs_repo
git config user.name "runic-docs-bot"
git config user.email "ci@whitlocktech.com"
mkdir -p "$(dirname "${DOCS_PATH}")"
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
git add "${DOCS_PATH}"
if git diff --cached --quiet; then
echo "PROJECT_TREE.md already up to date — nothing to sync."
exit 0
fi
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
git checkout -B "${PR_BRANCH}"
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
# Open a PR only if one isn't already open for this branch (a force-push
# to an existing open PR's head updates it in place).
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
"${API}/pulls?state=open&limit=50" \
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
if [ "${OPEN}" = "0" ]; then
curl -sSf -X POST "${API}/pulls" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg head "${PR_BRANCH}" \
--arg base "main" \
--arg title "docs(tree): sync ${DOCS_PATH}" \
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
'{head: $head, base: $base, title: $title, body: $body}')" \
>/dev/null
echo "Opened a new docs PR for ${PR_BRANCH}."
else
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
fi

4
.gitignore vendored
View File

@@ -6,6 +6,10 @@ server/node_modules/
# build output # build output
client/dist/ client/dist/
# test coverage (generated in CI for SonarQube)
server/coverage/
coverage/
# env / secrets # env / secrets
.env .env
*.env *.env

View File

@@ -1,329 +0,0 @@
# UOMysticmoon Website — Backend Design
> Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding.
> This document is the contract the later phases build against.
Public contact email: **UOMysticmoon@gmail.com**
---
## 1. Stack & top-level decisions
| Concern | Decision | Rationale |
|---|---|---|
| Runtime | Node.js + Express | serverlinkr pattern |
| Database | MariaDB (own container) | spec; `mariadb` pool, parameterized SQL, no ORM (keeps the lightweight `model`/`db` split from serverlinkr) |
| Auth | JWT in an **httpOnly cookie** | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), `SameSite=Strict` covers CSRF for a same-origin admin panel |
| Frontend | React + Vite, same repo, served by Express in prod | spec |
| Hashing | bcrypt (`bcryptjs`) | spec; matches serverlinkr |
| Deploy | Docker Compose (app + db) behind Pangolin | spec |
**Adapting serverlinkr → this project**
- `*.mongo.js` (mongoose) → `*.db.js` (MariaDB queries), exactly as the spec names them.
- Drop the session/passport hybrid (`express-session`, `passport`, `passport-local`, `connect-mongo`). Pure stateless JWT instead — simpler and matches "JWT auth".
- Routes grouped by **access level** (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by **entity**.
---
## 2. Folder structure
Skeleton from the spec, with a small number of justified additions marked **(+)**.
```
server/
.env.example
package.json
db/
schema.sql (+) DDL, also auto-run by the MariaDB container
seed.js (+) seed wiki pages, default settings, first admin
src/
server.js bootstrap: ensure schema, then listen on 0.0.0.0
app.js express app + middleware wiring
router/
api.router.js mounts /v1
v1/
v1.router.js mounts /auth /public /admin
auth/ auth.routes.js + auth.controller.js
public/ public.routes.js + public.controller.js
admin/ admin.routes.js + admin.controller.js
model/
users/ users.model.js + users.db.js
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
wiki/ wiki.model.js + wiki.db.js
settings/ settings.model.js + settings.db.js
activity/ activity.model.js + activity.db.js (+) admin activity log
middleware/ (+)
siteMode.js LIVE/MAINTENANCE gate for public content
noindex.js X-Robots-Tag: noindex,nofollow on admin
rateLimit.js login limiter
validate.js express-validator error handler
utils/
auth.js JWT sign/verify, isLoggedIn middleware
db.js MariaDB pool + ensureSchema()
mailer.js (+) nodemailer; mailto fallback if SMTP unset
client/ built in Phase 2/3 (React + Vite)
Dockerfile
docker-compose.yml
.env.example
.gitignore
```
**Why the additions:** the spec's feature list requires an activity log, a maintenance-mode
gate, login rate limiting, admin `noindex`, and SMTP email — none fit cleanly in the four
listed models/two utils. They're isolated in `middleware/` + one `activity` model +
`utils/mailer.js`, and the spec explicitly says the layout is "expandable."
---
## 3. Database schema (MariaDB)
`utf8mb4` throughout. Created idempotently on boot (`ensureSchema()`) **and** shipped as
`db/schema.sql` for the container's `/docker-entrypoint-initdb.d`.
### users
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| username | VARCHAR(32) UNIQUE NOT NULL | |
| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API |
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| last_login_at | DATETIME NULL | shown in user management |
### posts — one table, four categories
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | |
| title | VARCHAR(200) NOT NULL | |
| slug | VARCHAR(220) NULL | optional clean URL |
| excerpt | VARCHAR(400) NULL | list teaser |
| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter |
| image_url | VARCHAR(500) NULL | required for `screenshot`, optional hero elsewhere |
| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle |
| author_id | INT NULL FK→users(id) | ON DELETE SET NULL |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
| published_at | DATETIME NULL | set when first published; list order |
Index: `(category, published, published_at DESC)`.
### wiki_pages
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `new-player-guide` |
| title | VARCHAR(200) NOT NULL | |
| body | MEDIUMTEXT NULL | markdown/HTML |
| updated_by | INT NULL FK→users(id) | |
| created_at / updated_at | DATETIME | |
Seeded with the 8 spec categories: `new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules`.
### settings — key/value, expandable
| col | type | notes |
|---|---|---|
| `key` | VARCHAR(64) PK | |
| value | TEXT NULL | |
| updated_by | INT NULL FK→users(id) | |
| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP | |
Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
### activity_log — append-only
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NULL FK→users(id) | |
| action | VARCHAR(64) NOT NULL | e.g. `auth.login`, `site_mode.change`, `post.create` |
| detail | TEXT NULL | JSON string of what changed |
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
---
## 4. API contract
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
accepts `Authorization: Bearer` for API testing).
### /auth (auth.routes.js → auth.controller.js)
| Method | Path | Auth | Body | Purpose |
|---|---|---|---|---|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| POST | `/logout` | cookie | — | clear cookie |
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
admins are created under `/admin/users`.
### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes |
|---|---|---|
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
| GET | `/status` | status message + current mode |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
| GET | `/posts/:category/:idOrSlug` | single published post |
| GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
Public content GETs pass through the **siteMode** gate (§5).
### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
| Method | Path | Purpose |
|---|---|---|
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
| PUT | `/site-mode` | `{mode}` → update settings, stamp who/when, log `site_mode.change` |
| GET | `/posts?category=` | all posts incl. unpublished |
| POST | `/posts` | create |
| GET | `/posts/:id` | one |
| PUT | `/posts/:id` | edit |
| DELETE | `/posts/:id` | delete |
| PATCH | `/posts/:id/publish` | `{published}` toggle (sets `published_at`) |
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
Every admin write logs to `activity_log`.
---
## 5. Site mode (LIVE / MAINTENANCE)
State in `settings.site_mode` (`live`|`maintenance`), default **maintenance**.
`middleware/siteMode.js`, applied only to **public content** routes:
- `live` → pass through.
- `maintenance` → respond **503** with `{mode:"maintenance", message}` **unless** the request
carries a valid admin cookie (admin preview). This hides content server-side, not just in
the UI.
Always reachable regardless of mode: static assets / SPA shell, `/api/v1/auth/*`, all
`/api/v1/admin/*`. So admin login + panel + the maintenance "coming soon" page always load.
**Client behavior (Phase 3):** reads `GET /public/settings`; if `maintenance` and not an
admin previewing, render the polished dark coming-soon page (message + contact email).
Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.
Dashboard reads `site_mode` + `site_mode_changed_at`/`_by` for "current mode + last change +
who"; `activity_log` provides the history feed.
---
## 6. Auth & security
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
- **No directory browsing** (express.static doesn't list; no `serve-index`).
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
- **`app.set('trust proxy', 1)`** so secure cookies, `req.ip`, and rate-limiting work behind Pangolin.
- **CORS**: same-origin in prod (SPA served by Express). Dev only: allow `CLIENT_ORIGIN` (Vite, `http://localhost:5173`) with `credentials:true`.
---
## 7. Email
`utils/mailer.js` (nodemailer) sends through **Gmail over OAuth2 (SMTP XOAUTH2)**, configured in
Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent
flow (`/admin/email/*`) that captures a refresh token, stored AES-GCM-encrypted in the `email_config`
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.
---
## 7.5 Logging & observability
`utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).
- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
`/app/logs/app.log` in Docker, bind-mounted to `./logs`); verbosity = `FILE_LOG_LEVEL`
(default `debug`, so the file keeps a complete record while the console stays readable).
Toggle with `LOG_TO_FILE`. The stream is flushed on graceful shutdown.
- **HTTP access logs** via morgan piped into the logger: real client IP (`trust proxy`),
authenticated admin username, method, URL, status, response time, size.
- **Captured events**: startup config banner, schema/seed steps, login success/failure,
rate-limit hits, site-mode changes, maintenance-gate blocks (debug), all errors with
stack traces (5xx), and SIGINT/SIGTERM shutdown. Passwords and request bodies are never
logged. `unhandledRejection`/`uncaughtException` are caught and logged.
## 8. Deployment
**docker-compose.yml** — two services on a private network:
- `db`: `mariadb:11`, env `MARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD`, volume
`dbdata:/var/lib/mysql`, mounts `schema.sql` into `/docker-entrypoint-initdb.d`, healthcheck.
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
`ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
- Volumes: `dbdata`, `uploads`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
**.env.example** (committed; real `.env` ignored):
```
NODE_ENV=production
PORT=3000
DB_HOST=db
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_PASSWORD=
DB_ROOT_PASSWORD=
JWT_SECRET=
JWT_EXPIRES_IN=1d
COOKIE_SECURE=true
COOKIE_NAME=uomm_token
ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
CLIENT_ORIGIN=http://localhost:5173
```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
---
## 9. Dependencies (server)
`express, cors, helmet, morgan, dotenv, mariadb, jsonwebtoken, bcryptjs, cookie-parser,
express-rate-limit, express-validator, multer, nodemailer` · dev: `nodemon`.
Removed vs serverlinkr: `mongoose, mongodb, connect-mongo, express-session, passport,
passport-local`.
---
## 10. Spec coverage
| Spec requirement | Covered by |
|---|---|
| Public pages (`/`, `/site/*`, `/wiki/*`) | `/public/*` API + Phase-3 SPA routes; content from `posts`/`wiki`/`settings` |
| News / 5-on-Friday / Newsletter / Screenshots | `posts` table, `category` column; admin CRUD + publish |
| Wiki 8 categories, editable later | `wiki_pages` seeded with 8 slugs; admin CRUD |
| Status page | `settings.status_message` + mode via `/public/status` |
| Admin dashboard (mode, last change, who) | `/admin/dashboard` + settings stamps + activity log |
| Site mode toggle | `PUT /admin/site-mode` + `siteMode` middleware |
| Admin activity log | `activity_log` + `/admin/activity` |
| Admin user management | `/admin/users` CRUD |
| Site settings editing | `/admin/settings` |
| JWT, bcrypt, rate limit, secure cookies, noindex, no dir browsing, no hardcoded creds, .env | §6 |
| Maintenance page, admin always in, static always loads, admin preview | §5 |
| SMTP via env, mailto fallback | §7 |
| Docker Compose + MariaDB + Pangolin, 0.0.0.0 bind | §8 |
| Design tokens / hero | reused from existing `assets/css/mysticmoon.css` + hero PNG in Phase 2/3 |
| Expandable | key/value settings, role enum, modular routers/models |
```

133
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,133 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**whitlocktech@gmail.com**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

100
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,100 @@
# Contributing to Runic Gateway — Website
Thanks for your interest in contributing! This repo is the full-stack website
(Node.js + Express API, MariaDB, React + Vite SPA). This guide covers how to get
set up, the workflow we follow, and the rules for contributions.
By participating you agree to abide by our
[Code of Conduct](CODE_OF_CONDUCT.md).
## Ways to contribute
- **Report a bug** or **request a feature** through the
[issue tracker](https://gitea.whitlocktech.com/RunicGateway/website/issues)
(issue templates are provided).
- **Improve the code or docs** by opening a pull request (see below).
- **Never** report a security vulnerability in a public issue — see
[SECURITY.md](SECURITY.md).
## Development setup
**Prerequisites:** Node.js 20+ and npm, plus Docker (for MariaDB).
The [README](README.md) has the full setup guide. The short version for local
development with hot reload:
```bash
# 1. Start a MariaDB the backend can reach
docker run -d --name rg-db -p 3306:3306 \
-e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic \
-e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
# 2. Backend (terminal 1)
cp server/.env.example server/.env # set DB_* , JWT_SECRET, ADMIN_USERNAME/PASSWORD
npm run install-all
npm run server # nodemon -> http://localhost:3000
# 3. Frontend (terminal 2)
npm run client # Vite -> http://localhost:5173
```
Develop against **http://localhost:5173** (the Vite dev server proxies `/api`).
### Tests & checks
Please run the server test suite and make sure the client builds before opening
a PR — these are the same checks CI runs on your PR:
```bash
npm test # server tests
npm run build # client production build
```
If you add or change an API route, regenerate the Swagger spec
(`cd server && npm run swagger`) and commit the updated
`server/swagger/swagger-output.json`.
The URL surface is also frozen by a generated manifest. If your change adds,
removes or renames a route, regenerate it (`cd server && npm run routes:manifest`)
and commit `server/routes.manifest.json` + `server/routes.guards.json` — CI fails
otherwise. A non-empty diff in `routes.manifest.json` means you changed the API
contract, so call it out in the PR description; a pure refactor must produce none.
## Branch & PR workflow
1. Fork or branch from `main`. Use a descriptive branch name
(`feature/…`, `fix/…`, `docs/…`, `chore/…`).
2. Keep changes focused; small PRs are easier to review.
3. Push and open a pull request against `main`. Fill out the PR template,
including the **AI-assisted contributions** disclosure.
4. Make sure PR checks (server tests + client build) are green.
5. A maintainer will review; address feedback by pushing follow-up commits.
### Commit messages
We use [Conventional Commits](https://www.conventionalcommits.org/) —
`type(scope): summary` (e.g. `feat(auth): add TOTP challenge step`,
`fix(brand): link footer badge to Gitea org`). Common types: `feat`, `fix`,
`docs`, `chore`, `refactor`, `test`, `ci`.
## AI-assisted contributions (disclosure required)
This project is developed openly with AI assistance, and we ask the same
transparency of everyone. **If you used an AI tool** (Claude, Copilot, ChatGPT,
Cursor, etc.) to help produce a contribution, you must disclose it:
- Tick the AI-usage box in the pull-request template and name the tool(s).
- Mark AI-authored commits with a trailer, e.g.
`Co-Authored-By: Claude <noreply@anthropic.com>` or `Assisted-By: <tool>`.
- You remain responsible for every line you submit: review it, understand it,
and make sure it is correct and that you have the right to contribute it.
Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are
not, and may be closed.
## License
Runic Gateway is licensed under the **GNU General Public License v3.0 or later**
(see [LICENSE.md](LICENSE.md)). By submitting a contribution you agree that it is
licensed under the same terms (inbound = outbound) and that you have the right to
contribute it.

31
CONTRIBUTORS.md Normal file
View File

@@ -0,0 +1,31 @@
# Contributors
Runic Gateway is built and maintained by the people and tools listed here.
Thank you to everyone who has contributed.
## Maintainers
- **whitlocktech** &lt;whitlocktech@gmail.com&gt; — project lead and maintainer
## Contributors
<!--
Add yourself here when your contribution is merged — alphabetical by name or
handle. One line each:
- **Name or handle** (optional link) — what you contributed
-->
- _Your name could be here — see [CONTRIBUTING.md](CONTRIBUTING.md)._
## AI-assisted development
Parts of Runic Gateway were developed with the assistance of AI coding tools,
including **Claude** (Anthropic) via Claude Code. AI-assisted commits are
attributed in their commit trailers (e.g. `Co-Authored-By: Claude ...`).
In keeping with this project's transparency policy, **all contributors must
disclose their use of AI tools** on any contribution — see the
"AI-assisted contributions" section of [CONTRIBUTING.md](CONTRIBUTING.md).
Disclosed AI assistance is welcome; undisclosed AI-generated contributions are
not.

View File

@@ -1,134 +0,0 @@
# UOMysticmoon — Hero Canvas Editor Spec
> Branch: **`hero-feature`**. Build contract for the WYSIWYG portal-hero editor.
> Derived from the design doc *Hero Canvas Editor — Design Document*, **corrected
> to match the current codebase** and with the open questions resolved.
> Same workflow as the wiki upgrade: design → phased build → verify.
## 1. Goal
Let staff compose the portal hero (background image, overlay opacity, and floating
elements — text, CTA buttons, moon, badge, image) in-browser, then preview and
publish — no source edits. Layout persists as JSON in the existing `settings` table.
## 2. Locked decisions
| # | Decision |
|---|---|
| Scope | **Full v1** — background/overlay, all element types, drag/resize/z-order, draft→preview→publish (built in phases) |
| CTA buttons | **First-class `buttons` element type** (independently positioned), not baked into a text block |
| First run | **Pre-populate** the canvas with today's hero (headline, subtitle, teaser, CTAs) as editable elements so nothing changes visually until edited |
| Drag | **Native Pointer Events** (mouse/touch/pen), zero dependencies |
| Font size | Stored in **px** (fixed reference canvas) |
| Image compression | **None** server-side; client warns when a file is > ~1 MB |
| Preview | `?preview=1` renders the **draft** by reading it through the authenticated admin settings endpoint |
| Other pages | Out of scope for v1 (design allows a per-page key later) |
## 3. Corrections to the design doc (current-code reality)
1. **Public settings is a whitelist, not `getAll()`.** `GET /api/v1/public/settings`
`settings.getPublic()``PUBLIC_KEYS` in
[settings.model.js](server/src/model/settings/settings.model.js). The doc's
"no backend changes / picked up automatically" is wrong. **Fix:** add
`hero_layout` to `PUBLIC_KEYS` (one line). `hero_layout_draft` stays out
(admin-only) — which is why preview reads the draft via `api.admin.getSettings()`.
2. **Moon is a reusable component** ([MoonDot.jsx](client/src/components/MoonDot.jsx),
props `size`/`glow`), used in logo/login/maintenance — not "only the header."
The `moon` element reuses it; it gains an optional `color`.
3. **Route vs. nav live in different files.** `/admin/hero` route →
[App.jsx](client/src/App.jsx); sidebar link/title → `NAV`/`TITLES` in
[AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx).
4. **Admin content area is `maxWidth: 1000px`** — the editor canvas renders
scaled-to-fit; percentage positions stay faithful.
Everything else in the doc matches (hardcoded `HERO_BG` + CTAs + `homepage_teaser`
in [Portal.jsx](client/src/routes/public/Portal.jsx); `updateSettings` accepts
arbitrary keys; `/admin/uploads` exists; default hero asset present; TEXT settings
columns — no schema change).
## 4. Data model — no schema change
Two `settings` keys (TEXT): `hero_layout` (live) and `hero_layout_draft` (admin).
```jsonc
{
"version": 1,
"background": { "image_url": null, "position_x": "left", "position_y": "center", "size": "cover" },
"overlay": { "opacity": 0.72 },
"elements": [
{ "id": "uuid", "type": "text_block|buttons|moon|badge|image",
"x": 50, "y": 42, "z": 1, "anchor": "center", "props": { /* per type */ } }
]
}
```
Positions are **% of canvas** (reference width 1080, matching `.shell`), so the
layout adapts across viewports without breakpoint data. `version` is validated
(`=== 1`) before use; anything else falls back.
### Element props
| Type | Props |
|---|---|
| `text_block` | `lines: [{ text, tag(h1/h2/p/span), fontSize(px), color, weight }]`, `align` |
| `buttons` | `items: [{ label, to, variant(primary/ghost) }]`, `align`, `gap` |
| `moon` | `size`, `glow`, `color` |
| `badge` | `text`, `bgColor`, `textColor`, `borderRadius` |
| `image` | `src`, `width`(%), `alt` |
## 5. Backend changes
- **One line:** add `'hero_layout'` to `PUBLIC_KEYS`. No new routes/controllers —
layout saves through the existing `PUT /admin/settings`; images via `/admin/uploads`.
## 6. Frontend changes
- **New** `client/src/components/HeroElement.jsx` — renders one element by type
(shared by the live portal and the editor canvas).
- **New** `client/src/routes/admin/views/HeroEditor.jsx` — canvas + element tray +
properties panel; native-pointer drag/resize; background/overlay panel; snap grid;
auto-save draft, preview, publish, revert.
- **Edit** [Portal.jsx](client/src/routes/public/Portal.jsx) — parse `hero_layout`
(or draft when `?preview=1` + admin), render elements, fall back to a
`DEFAULT_LAYOUT` built from today's hero so the page is unchanged until edited.
- **Edit** [AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx) (nav) +
[App.jsx](client/src/App.jsx) (route `/admin/hero`).
- **Edit** [MoonDot.jsx](client/src/components/MoonDot.jsx) — optional `color`.
- **No** `client/src/api/client.js` changes needed beyond what exists
(`admin.updateSettings`, `admin.getSettings`, `admin.upload`).
## 7. Phased build (each phase: build → verify in preview → commit)
- **Phase 0 — Spec** ✅ this document.
- **Phase 1 — Data path & renderer** ✅ (verified 2026-06-28). `hero_layout`
whitelisted; `HeroElement.jsx`; Portal renders the layout with a `DEFAULT_LAYOUT`
fallback. Default render matches the old hero; publishing a layout re-renders;
draft key not exposed publicly. Shared helpers moved to `client/src/lib/heroLayout.js`.
- **Phase 2 — Editor shell + background/overlay** ✅ (verified 2026-06-28).
`/admin/hero` view + sidebar nav; canvas live-preview; background upload + 3×3
position + overlay opacity; debounced draft auto-save; publish; `?preview=1`
reads the draft (admin) with a banner; revert. Verified: overlay/position update
the canvas, auto-save writes the draft, publish writes live, preview shows the
draft while the normal portal shows live.
- **Phase 3 — Elements: select / drag / text_block / buttons** ✅ (verified
2026-06-28). Element tray (+ Text / + Buttons); click-to-select with outline;
native Pointer Events drag (% of canvas); Delete key + panel delete; z-order
(send back / bring forward); text_block line editor (text/tag/size/color/bold,
add/remove lines, align) and buttons editor (label/path/variant, add/remove).
Verified: select shows the line editor, editing a line updates the canvas live,
drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects.
- **Phase 4 — moon + badge + image + resize + snap grid** ✅ (verified 2026-06-28).
Tray adds moon/badge/image; property panels (moon: size/glow/color; badge:
text/colors/radius; image: upload/width/alt); corner resize handle (image→width%,
moon→size, text→box width); 8px snap-grid toggle with overlay; image placeholder
until a file is chosen. Verified: each type adds + edits, resize moved a moon
64→104px, snap grid shows, and a published moon+badge render on the live portal.
**Status: v1 feature-complete.** All phases verified end-to-end; ready for PR.
Deferred (noted in the design doc as follow-ups): 8-point resize (only a corner
handle for now), per-viewport layouts, server-side image compression.
## 8. Edge cases (from the doc, carried forward)
- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.
- Element ids via `crypto.randomUUID()` (never array index).
- Empty `elements` → render `DEFAULT_LAYOUT` so the hero is never blank.
- Last-write-wins on concurrent admin edits (acceptable for this shard).
- Client-side warning for background files > ~1 MB (no hard block; 8 MB server cap).

674
LICENSE.md Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

284
README.md
View File

@@ -1,19 +1,31 @@
# UOMysticmoon Website # Runic Gateway Website
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online [![Bugs](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=bugs&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
shard — a full-stack app in one repo: [![Code Smells](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=code_smells&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Duplicated Lines (%)](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=duplicated_lines_density&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Lines of Code](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=ncloc&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Security Hotspots](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=security_hotspots&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Security Rating](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=security_rating&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Vulnerabilities](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=vulnerabilities&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
[Branding](#branding)); **UOMysticmoon** is the first instance.
A full-stack app in one repo:
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO). - **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia). - **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production. - **Deploy** — Docker Compose (app + MariaDB) behind a reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). Express serves the built SPA in production.
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([UOM/link](https://gitea.whitlocktech.com/UOM/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link). - **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security). The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives.
--- ---
## Contents ## Contents
- [Architecture](#architecture)
- [Tech stack](#tech-stack) - [Tech stack](#tech-stack)
- [Project structure](#project-structure) - [Project structure](#project-structure)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
@@ -29,7 +41,103 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
- [Environment variables](#environment-variables) - [Environment variables](#environment-variables)
- [Security](#security) - [Security](#security)
- [Logging](#logging) - [Logging](#logging)
- [Deployment behind Pangolin](#deployment-behind-pangolin) - [Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)
---
## Architecture
How the pieces fit together — the React SPA and native app talk to one Express backend
(`router → controller → model → db`), which persists to MariaDB and bridges to the live
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
```mermaid
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ &nbsp;— Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class sidecar,servuo bridge;
```
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
All three surfaces produce the *same* session via the session layer.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
--- ---
@@ -43,14 +151,14 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
| Frontend | React 18, Vite 5, React Router 6 | | Frontend | React 18, Vite 5, React Router 6 |
| Email | Nodemailer via Gmail OAuth2 (configured in admin), 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, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
--- ---
## Project structure ## Project structure
``` ```
UOMSITE/ website/
├─ server/ Express API ├─ server/ Express API
│ ├─ src/ │ ├─ src/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0) │ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
@@ -92,8 +200,10 @@ UOMSITE/
### Option A — Docker Compose (full stack) ### Option A — Docker Compose (full stack)
The simplest way to run everything. The image installs server deps, **builds the React client**, `docker-compose.yml` is **production-shaped**: it *pulls* the prebuilt `app` and `bot` images from
and Express serves it; MariaDB runs in its own container; tables + defaults + the first admin are the Gitea container registry (published by `.gitea/workflows/build-images.yml` on every merge to
`main`) — it never builds. Each image already bundles the server deps and the built React client,
which Express serves. MariaDB runs in its own container; tables + defaults + the first admin are
created automatically on first boot. created automatically on first boot.
```bash ```bash
@@ -103,7 +213,9 @@ cp .env.example .env
# JWT_SECRET (a long random string) # JWT_SECRET (a long random string)
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login) # ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
docker compose up -d --build docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
# pin a specific build (reproducible deploy / rollback):
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
``` ```
- App: **http://localhost:3000** (binds `0.0.0.0`) - App: **http://localhost:3000** (binds `0.0.0.0`)
@@ -111,6 +223,16 @@ docker compose up -d --build
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host) - Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes) - Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
the dev file, which adds `build:` back:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
```
Keeping `build:` out of the base file means a production host can only ever pull — it can never
accidentally build.
### Option B — Local development (hot reload) ### Option B — Local development (hot reload)
Run the API and the Vite dev server separately. The Vite server proxies `/api` and `/uploads` Run the API and the Vite dev server separately. The Vite server proxies `/api` and `/uploads`
@@ -119,14 +241,14 @@ to the backend, so the SPA stays same-origin (cookies work).
**1. Start a MariaDB the backend can reach** (published on `localhost:3306`): **1. Start a MariaDB the backend can reach** (published on `localhost:3306`):
```bash ```bash
docker run -d --name uomm-db -p 3306:3306 -e MARIADB_DATABASE=uomysticmoon -e MARIADB_USER=uomm -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11 docker run -d --name rg-db -p 3306:3306 -e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
``` ```
**2. Configure + start the backend** (terminal 1): **2. Configure + start the backend** (terminal 1):
```bash ```bash
cp server/.env.example server/.env cp server/.env.example server/.env
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=uomm, DB_PASSWORD=devpass, # Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=runic, DB_PASSWORD=devpass,
# JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password> # JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password>
npm run install-server npm run install-server
npm run server # nodemon → http://localhost:3000 npm run server # nodemon → http://localhost:3000
@@ -215,7 +337,7 @@ npm start # node server → serves API + SPA at http://localhost:3
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`. Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`. `authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger See [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
docs below for a per-endpoint reference (parameters, request bodies, response codes). docs below for a per-endpoint reference (parameters, request bodies, response codes).
--- ---
@@ -235,7 +357,7 @@ actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `4
**Authentication in the UI** — click **Authorize** and provide either: **Authentication in the UI** — click **Authorize** and provide either:
- `cookieAuth` — the `uomm_token` session cookie (set automatically in the browser after - `cookieAuth` — the session cookie (name `rg_token`, configurable via `COOKIE_NAME`; set automatically in the browser after
`POST /api/v1/auth/login`), or `POST /api/v1/auth/login`), or
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as - `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
`Authorization: Bearer <token>`). `Authorization: Bearer <token>`).
@@ -254,13 +376,42 @@ npm run swagger # → server/swagger/swagger-output.json
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
not crash). not crash).
### The route manifest (frozen URL surface)
`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
Express listeners actually expose. It is **not** documentation — it is the machine-checkable freeze of
the URL surface, so that carving the router files up by business capability
(`docs/website/API_V2_PLAN.md`) can be proved to move no URL instead of merely claiming it.
```bash
cd server
npm run routes:manifest # → routes.manifest.json + routes.guards.json
npm run routes:manifest -- --check # exit 1 if either file is stale (what CI runs)
```
The generator walks the live Express stack (runtime introspection, not source parsing — route paths in
`admin.routes.js` sit on the line *after* `adminRouter.get(`, which defeats greps) and keeps only
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` and `/brand`
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does
not depend on whether the client has been built.
Two generated files, two very different meanings:
| File | Meaning of a diff |
|---|---|
| `routes.manifest.json` | **Contract change.** A URL moved. Justify it in the PR description; never let one ride along in a "mechanical" refactor. |
| `routes.guards.json` | **Review aid.** Per route: handler count + the *named* middleware on its mount chain. Names are a hint only — `requireRole(...)` returns an anonymous arrow and cannot be seen — but a vanished `requireAuth` is unambiguous. |
Unlike the Swagger spec, the manifest is annotation-free: `swagger-output.json` documents intent (only
annotated routes appear), the manifest records reality.
--- ---
## Shard integration (uo-link) ## Shard integration (uo-link)
The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that
runs next to the ServUO shard. Its source lives in a separate repo: runs next to the ServUO shard. Its source lives in a separate repo:
**[UOM/link](https://gitea.whitlocktech.com/UOM/link)**. uo-link speaks the shard's internals and **[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and
exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard
itself is never exposed to the internet — only the sidecar is, and only the website's backend talks itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
to it. to it.
@@ -268,7 +419,7 @@ to it.
### How it works ### How it works
``` ```
ServUO shard ──▶ uo-link sidecar (UOM/link) ──▶ website backend ──▶ browser ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
``` ```
@@ -334,19 +485,20 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` | | `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) | | `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev | | `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials | | `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) | | `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens | | `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime | | `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) | | `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + proxy HTTPS) |
| `COOKIE_NAME` | `uomm_token` | | | `COOKIE_NAME` | `rg_token` | changing it on a live instance invalidates existing sessions |
| `BRAND_*` | Runic Gateway | instance branding (name, tagline, colors, logo/hero/favicon) — see [Branding](#branding) |
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) | | `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
| `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) | | `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
| `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) | | `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) |
| `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) | | `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) |
| `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing | | `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing |
| `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off | | `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off |
| `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA | | `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step | | `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) |
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting | | _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
@@ -358,6 +510,32 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
--- ---
## Branding
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
image can run as any shard. With none set, everything renders as **Runic Gateway**.
| Var | What |
|---|---|
| `BRAND_NAME` / `BRAND_SHORT_NAME` | display name (full / short-in-prose) |
| `BRAND_TAGLINE` / `BRAND_DESCRIPTION` | tagline + meta/OG description |
| `BRAND_CONTACT_EMAIL` / `BRAND_URL` | contact + canonical URL (for OG/absolute links) |
| `BRAND_ACCENT_COLOR` | theme `--accent` (web) + Discord embed color |
| `BRAND_LOGO` / `BRAND_HERO` / `BRAND_FAVICON` | image paths under the `/brand` mount, or absolute URLs |
**How it flows:** text/colors reach the SPA at runtime through the public settings
API (`SiteContext`), so no rebuild is needed; the server templates `index.html`
`<title>`/meta/OG/favicon at boot; emails, TOTP issuer, and the Discord bot read
`BRAND_*` directly. The admin-editable **site title** and **contact email**
settings override `BRAND_NAME` / `BRAND_CONTACT_EMAIL` when set. Image assets are
delivered from the `./brand` bind-mount (see `brand/README.md`).
**UOMysticmoon** is the first instance — [`.env.uomysticmoon.example`](.env.uomysticmoon.example)
holds the exact `BRAND_*` + infra (`DB_NAME`/`DB_USER`/`COOKIE_NAME`) pinning to
run this repo as UOMysticmoon.
---
## Security ## Security
**Session & authorization** **Session & authorization**
@@ -416,7 +594,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
**Platform** **Platform**
- `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 a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail `.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the 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. contact form falls back to a `mailto:` link when unconfigured.
@@ -443,10 +621,60 @@ bind-mounted to `./logs/app.log` and `docker compose logs -f app` shows the cons
--- ---
## Deployment behind Pangolin ## Deployment behind a reverse proxy
`docker compose up -d --build` exposes the `app` container on `0.0.0.0:3000` (no `127.0.0.1` `docker compose up -d --build` exposes the `app` container on `0.0.0.0:3000` (no `127.0.0.1`
binding) so Pangolin can reach it. Point a Pangolin resource at `app:3000`. Because `COOKIE_SECURE` binding) so a reverse proxy — Pangolin, Nginx, Caddy, Traefik, etc. — can reach it. Point the
defaults to `auto`, the admin login works both directly via the LAN IP over HTTP **and** through proxy at `app:3000` (or the host's `:3000` if the proxy runs outside Compose) and terminate TLS
Pangolin over HTTPS — no config change needed. MariaDB stays on the private Compose network there. Because `COOKIE_SECURE` defaults to `auto`, the admin login works both directly via the
(no published port by default); data persists in the `dbdata` volume, uploads in `uploads`. LAN IP over HTTP **and** through the proxy over HTTPS — no config change needed. MariaDB stays on
the private Compose network (no published port by default); data persists in the `dbdata` volume,
uploads in `uploads`.
Set `TRUST_PROXY` so Express reads the real client IP from the proxy's `X-Forwarded-For` header
(see [Environment variables](#environment-variables)) — required for rate limiting, bot scoring,
and correct logging. Forward the standard `X-Forwarded-For` and `X-Forwarded-Proto` headers from
your proxy.
Minimal proxy examples:
```nginx
# Nginx
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
```caddy
# Caddy — Caddyfile (automatic HTTPS; forwards X-Forwarded-* by default)
your.domain {
reverse_proxy app:3000
}
```
**Pangolin:** create a resource targeting `app:3000`; it forwards the required headers and
terminates HTTPS out of the box, so no extra configuration is needed.
---
## License
Runic Gateway is free software, licensed under the **GNU General Public License
v3.0 or later** — see [LICENSE.md](LICENSE.md).
Copyright (C) 2026 Runic Gateway
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version. It is distributed WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) (note
the **AI-usage disclosure** requirement) and our
[Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately per
[SECURITY.md](SECURITY.md).

50
SECURITY.md Normal file
View File

@@ -0,0 +1,50 @@
# Security Policy
Thank you for helping keep Runic Gateway and its users safe.
## Reporting a vulnerability
**Please do not report security vulnerabilities through public issues, pull
requests, or the wiki.** A public report tips off attackers before a fix is
available.
Instead, report privately by email to:
**whitlocktech@gmail.com**
Please include as much of the following as you can:
- The repository and component affected.
- The type of issue (e.g. authentication bypass, injection, secret exposure,
remote code execution, denial of service).
- Step-by-step instructions to reproduce, and a proof-of-concept if you have one.
- The impact — what an attacker could do with it.
- Any suggested remediation.
You will receive an acknowledgement of your report, typically within a few days.
We will keep you informed as we investigate and work toward a fix, and we are
happy to credit you in the release notes once the issue is resolved (let us know
if you would prefer to remain anonymous).
## Scope
Runic Gateway is a self-hosted platform made up of several components:
| Component | Repo | Network exposure |
|---|---|---|
| Website (site + admin + API) | `RunicGateway/website` | Internet-facing (behind a reverse proxy) |
| uo-link sidecar | `RunicGateway/link` | The only network-facing part of the game bridge |
| ServUO plugin | `RunicGateway/servuo-plugins` | Loopback only — dials the sidecar on `127.0.0.1` |
| Documentation | `RunicGateway/docs` | Content only |
Because instances are self-hosted, the security of any given deployment also
depends on how it is configured and operated — strong secrets (`JWT_SECRET`,
`SECRET_ENC_KEY`, database and admin passwords), a correctly configured reverse
proxy and `TRUST_PROXY`, and keeping the shard itself unreachable from the
internet (only the sidecar should be exposed). See each repo's README for the
security model.
## Supported versions
This project is developed continuously and does not maintain long-term release
branches. Security fixes land on `main`; please run a recent build.

View File

@@ -1,366 +0,0 @@
# UOMysticmoon Website — Wiki Upgrade Spec
> Branch: **`wiki-upgrade`**. This document is the contract for upgrading the CMS
> wiki from a flat single-table page store into a feature-complete wiki.
> It follows the project workflow: **design (this doc) → build in phases → verify**.
>
> Companion to [`BACKEND_DESIGN.md`](BACKEND_DESIGN.md); reuses its stack, auth,
> logging, and Docker decisions unchanged.
---
## 1. Goal & scope
Turn the wiki into something that behaves like a typical wiki, while staying inside
the existing Node/Express + MariaDB + React/Vite architecture and the **staff-only**
auth model (admin/editor — no new roles, no public contributions).
**In scope**
| Feature | Summary |
|---|---|
| Rich-text editing | TipTap (ProseMirror) WYSIWYG in the admin; outputs HTML |
| Sanitization | Server-side allowlist on save **and** client-side on render (fixes today's stored-XSS gap) |
| Categories / sections | First-class `wiki_categories` table; replaces hardcoded frontend blurbs |
| Drafts & publish | `published` + `published_at`, mirroring the `posts` pattern |
| Tags | Many-to-many tags with filtering |
| Internal links | `[[slug]]`-style links authored in the editor; red-link detection |
| Backlinks | "Linked from" list, maintained on save |
| Inline images | Reuse/generalize the existing multer upload for in-body images |
| Search | MariaDB `FULLTEXT` over title + body |
| Revision history | Per-save snapshots with view / diff / restore |
**Out of scope (this branch)**
- Public/player editing or suggestion workflow, moderation/review queues.
- New roles or per-page ACLs (all staff with a login can edit all pages).
- Real-time collaborative editing, comments/discussion pages, file attachments
other than images, page templates/transclusion, multilingual pages.
**Decisions locked from planning**
- Editor: **TipTap**, storing **HTML** (not Markdown, not JSON).
- Search: **MariaDB FULLTEXT** (no new infrastructure).
- Revision history and search are **included** (recommended additions beyond the
minimum requested set).
- Authoring is **admin + editor** (`isLoggedIn`); no anonymous edits.
---
## 2. Current state (baseline being replaced)
| Layer | Today | File |
|---|---|---|
| Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) |
| Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) |
| Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) |
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [admin.routes.js:68](server/src/router/v1/admin/admin.routes.js) |
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) |
| Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) |
| API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) |
**Known issues this upgrade resolves**
- **Stored XSS**: body is raw HTML rendered with `dangerouslySetInnerHTML` and never
sanitized ([WikiArticle.jsx:91](client/src/routes/wiki/WikiArticle.jsx)).
- Category blurbs and ordering are **faked in the component** ([Wiki.jsx:11](client/src/routes/wiki/Wiki.jsx)), not data.
- No drafts (every save is instantly public), no history, no search, no tags, no links.
---
## 3. Data model
`utf8mb4`, InnoDB throughout. All changes are **additive and idempotent** so
`ensureSchema()` upgrades existing databases on boot with no data loss. New columns
are nullable or have safe defaults; **existing pages default to `published = 1`** so
nothing disappears on deploy.
### 3.1 `wiki_categories` (new)
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `guides` |
| title | VARCHAR(200) NOT NULL | |
| description | VARCHAR(400) NULL | card teaser on the wiki index |
| sort_order | INT NOT NULL DEFAULT 0 | manual ordering |
| created_at / updated_at | DATETIME | standard stamps |
### 3.2 `wiki_pages` (altered)
Add to the existing table:
| col | type | notes |
|---|---|---|
| category_id | INT NULL FK→wiki_categories(id) ON DELETE SET NULL | |
| excerpt | VARCHAR(400) NULL | card/search teaser (replaces hardcoded blurbs) |
| published | TINYINT(1) NOT NULL DEFAULT 1 | draft/publish toggle |
| published_at | DATETIME NULL | set on first publish |
| sort_order | INT NOT NULL DEFAULT 0 | ordering within a category |
| FULLTEXT idx_wiki_search (title, body) | | search |
### 3.3 `wiki_tags` + `wiki_page_tags` (new)
```
wiki_tags( id PK, slug VARCHAR(120) UNIQUE, label VARCHAR(120) )
wiki_page_tags( page_id FK→wiki_pages ON DELETE CASCADE,
tag_id FK→wiki_tags ON DELETE CASCADE,
PRIMARY KEY(page_id, tag_id) )
```
### 3.4 `wiki_links` (new) — backlinks index
Rebuilt for a page on every save by parsing its body for internal links.
| col | type | notes |
|---|---|---|
| source_page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| target_slug | VARCHAR(120) NOT NULL | may point at a not-yet-created page (red link) |
| INDEX idx_wiki_links_target (target_slug) | | backlink lookups |
Backlinks for page X = `SELECT source pages WHERE target_slug = X.slug AND source is published`.
### 3.5 `wiki_revisions` (new) — history
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| title / body / excerpt | snapshot of content at save time | |
| category_id | INT NULL | snapshot |
| editor_id | INT NULL FK→users(id) | who saved |
| change_note | VARCHAR(280) NULL | optional summary |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
A revision is written **inside the same transaction** as each page create/update.
### 3.6 Seed changes
Rework [seed.js](server/db/seed.js): the current 8 hardcoded pages become **categories**
(title + the blurb currently living in the frontend), each seeded idempotently via a new
`seedDefaultCategory`. Existing seeded pages are migrated/attached where applicable.
`seedDefault` for pages stays `INSERT IGNORE` so reseeding is safe.
---
## 4. Backend changes
Keep the `model` (entity) / `db` (SQL) split and the route grouping by access level.
### 4.1 Models (`server/src/model/wiki/`)
- `wiki.db.js` — add SQL for: category CRUD; page list with `category`, `published`,
`q` (FULLTEXT) filters and ordering; tag upsert + attach/detach; `wiki_links` rebuild;
revision insert/list/get; backlink query.
- `wiki.model.js` — orchestration. On **create/update** (single transaction):
1. sanitize `body` with the allowlist (§6),
2. upsert the page,
3. insert a `wiki_revisions` snapshot,
4. parse body for internal links → rebuild `wiki_links` for the page,
5. sync tags.
- A small `wiki.links.js` helper: parse internal links out of the saved HTML
(anchors written by the editor as `href="/wiki/<slug>"` / a `data-wiki-slug` attr),
return the set of target slugs.
### 4.2 Public API (`/api/v1/public`)
| Method | Path | Notes |
|---|---|---|
| GET | `/wiki/categories` | ordered categories with page counts |
| GET | `/wiki?category=&tag=&q=` | **published only**; list/filter/search summaries |
| GET | `/wiki/:slug` | page + category + tags + backlinks (published only) |
Still passes through the `siteMode` maintenance gate like other public content.
### 4.3 Admin API (`/api/v1/admin`, behind `isLoggedIn` + `noindex`)
| Method | Path | Purpose |
|---|---|---|
| GET | `/wiki` | all pages incl. drafts (filters: category, tag, q, status) |
| GET | `/wiki/:slug` | one page incl. draft, tags, category |
| POST | `/wiki` | create (slug, title, body, excerpt, category_id, tags, published) |
| PUT | `/wiki/:slug` | update (allows slug rename — see §7) |
| PATCH | `/wiki/:slug/publish` | `{published}` toggle, stamps `published_at` |
| DELETE | `/wiki/:slug` | delete (cascades revisions/links/tags) |
| GET | `/wiki/:slug/revisions` | list snapshots |
| GET | `/wiki/:slug/revisions/:id` | one snapshot (for diff/preview) |
| POST | `/wiki/:slug/revisions/:id/restore` | restore (writes a new revision) |
| GET/POST/PUT/DELETE | `/wiki/categories[...]` | category CRUD + reorder |
| GET/POST | `/wiki/tags` | list/create tags |
| POST | `/uploads` | generalized image upload (see §4.4) → `{url}` |
Validation via `express-validator` (slug regex `^[a-z0-9-]+$`, title required, etc.),
centralized error handler unchanged. **Every write logs to `activity_log`**
(`wiki.create`, `wiki.update`, `wiki.publish`, `wiki.delete`, `wiki.revision.restore`,
`wiki.category.*`) following the existing convention.
### 4.4 Image uploads
Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.js))
into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both
the post editor and the wiki editor. Same size/mime limits. No new storage —
served from the existing `uploads/` volume.
---
## 5. Frontend changes
### 5.1 Admin
- **`WikiEditor.jsx`** — replace the raw-HTML `<textarea>` with a **TipTap** editor:
bold/italic/headings (H2 for TOC)/lists/quote/code, link tool, **image insert**
(uploads via `/admin/uploads`), and an **internal-link picker** (`[[`-triggered
autocomplete over existing slugs; flags red links). Adds: category dropdown, tag
input (create-on-type), excerpt field, **Save draft / Publish** actions, and a
**History** tab (revision list → preview → diff → restore).
- **`WikiAdmin.jsx`** — list gains status (draft/published), category column, and
filters; plus a **Categories** manager (CRUD + drag-to-reorder).
### 5.2 Public
- **`Wiki.jsx`** — fully data-driven: categories + real excerpts from the API
(delete the hardcoded `BLURBS`/`ROMAN` constants), a **search box**, optional
tag filter.
- **`WikiArticle.jsx`** — keep auto-TOC; add category breadcrumb, tag chips, a
**"Linked from"** backlinks section, "last updated by", and **render via DOMPurify**
(`dangerouslySetInnerHTML` only after sanitize).
### 5.3 API client & routes
- Extend [client/src/api/client.js](client/src/api/client.js) with the new public/admin
wiki calls (categories, search params, revisions, tags, uploads).
- Add a public search/category route if needed; admin categories view registered in
[App.jsx](client/src/App.jsx) under `/admin/wiki` (sub-tab, no new top-level route required).
### 5.4 Dependencies (new)
- **client**: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`,
`@tiptap/extension-image` (+ a small diff lib for history, e.g. `diff`); `dompurify`.
- **server**: `sanitize-html`.
(The client currently ships only React + react-router, so this is the first feature
dependency addition — keep the bundle lean, import only the extensions used.)
---
## 6. Security
- **Two-layer sanitization.** Server sanitizes on save with a strict `sanitize-html`
allowlist (headings, p, lists, blockquote, code/pre, a[href], img[src,alt],
strong/em, hr, table basics); strips scripts, event handlers, `javascript:` URLs,
styles. Client re-sanitizes with DOMPurify before render. The stored value is already
clean, so even direct DB edits or future API clients can't inject script.
- **Upload safety** unchanged from posts: mime allowlist (png/jpe/gif/webp/avif),
8 MB cap, random filenames, served as static files (no execution).
- **Authorization**: all mutating wiki/category/tag/upload routes stay behind
`isLoggedIn` (admin or editor). Public routes are read-only and published-only.
- **No secrets/logging changes**; reuse existing rate-limit, helmet/CSP, noindex.
CSP `img-src` already covers `/uploads`.
---
## 7. Migration & backward compatibility
- Schema migration is additive; run by `ensureSchema()` on boot and shipped in
`schema.sql` for fresh containers. Use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
/ `ADD INDEX` guarded for idempotency (MariaDB 11 supports `IF NOT EXISTS`).
- Existing pages: `published` backfills to `1`, `published_at` to `updated_at`,
`category_id` left NULL (surface as "Uncategorized" until assigned).
- **Slug rename** (new capability): on `PUT` slug change, update the page slug and
best-effort rewrite known internal links pointing at the old slug; old slug is not
auto-redirected (acceptable for a staff-curated wiki) — note in release notes.
- Public API response shape is **extended, not broken**: existing fields
(`slug`, `title`, `body`, `updated_at`) remain; new fields are additive, so the
current frontend keeps working between phases.
---
## 8. Implementation process (phased)
Each phase is a self-contained, shippable unit: build → run locally → verify in the
browser preview → commit on `wiki-upgrade`. Open a PR into `main` at the end (or per
phase if preferred). Do not merge a phase that hasn't been verified.
### Phase 0 — Branch & scaffolding ✅ (this doc)
- `wiki-upgrade` branch created; this spec committed.
### Phase 1 — Foundation & safety (highest value) ✅
- Schema: add `wiki_categories`, alter `wiki_pages` (category_id, excerpt, published,
published_at, sort_order, FULLTEXT), update `seed.js`.
- Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD;
public list filtered to published + categories endpoint.
- Client: data-driven `Wiki.jsx` (remove hardcoded blurbs); DOMPurify render in
`WikiArticle.jsx`; draft/publish + category in the (still-textarea) admin editor.
- **Exit check**: existing pages still render; XSS payload in body is neutralized;
draft pages hidden from the public list/article.
- **Verified** (2026-06-27): schema migration ran clean on MariaDB 11; XSS payload
(`<script>`, `onerror=`, `javascript:`) stripped server-side; drafts return 404 on
the public API and are absent from the public list while visible in admin; public
index is data-driven (categories + sections); article shows category breadcrumb;
client builds and server boots with no errors.
### Phase 2 — Authoring UX ✅
- TipTap editor replaces the textarea; generalized `/admin/uploads`; inline images.
- **Exit check**: create/edit a page with headings, a list, a link, and an inline
image; verify it renders sanitized on the public page.
- **Verified** (2026-06-27): `/admin/uploads` returns `{url}` and the file serves as
an image; a page authored with H2/H3, lists, a link, and an uploaded inline image
round-trips through the WYSIWYG and renders sanitized publicly (link `rel` forced,
`<script>` stripped); a toolbar edit (insert divider) saved and persisted. TipTap
is code-split into its own chunk (lazy-loaded), keeping it off the public bundle.
### Phase 3 — Connectivity ✅
- Internal `[[slug]]` links + red-link detection; `wiki_links` rebuild on save;
backlinks on the article; tags + tag/category filtering.
- **Exit check**: link page A→B, confirm B shows A under "Linked from"; tag filter works.
- **Verified** (2026-06-27): internal links authored via an in-editor page picker
(links to `/wiki/<slug>`); A→B made B list A under "Linked from"; a link to a
non-existent page renders as a red link; removing the link on save cleared the
backlink (link index rebuilt). Tags upsert on save, filter via `?tag=` (chips +
flat index view), list with published counts, and orphan tags are auto-pruned.
- Implementation note: links are plain anchors to `/wiki/<slug>` (the WYSIWYG fits
this better than `[[ ]]` syntax); the sanitizer also allows `data-wiki-slug`.
### Phase 4 — Discovery & trust ✅
- FULLTEXT search (public search box + admin filter); revision history list /
diff / restore.
- **Exit check**: search returns expected pages; edit a page twice, diff the
revisions, restore an older one, confirm a new revision is recorded.
- **Verified** (2026-06-27): `?q=` natural-language search matches on both body
(`recipes`→crafting) and title (`monsters`); the public search box and admin
filter both work. A page edited twice produced 3 revisions; the History modal
shows a word-level diff (added vs removed) of an old revision against current;
restoring reverted the page and appended a "Restored from revision #N" entry.
### Verification (every phase)
Use the preview workflow, not manual hand-off: start the dev server, exercise the
public wiki and the admin editor, check console/network for errors, and capture a
screenshot of the changed surface. Confirm `npm run` lint/build passes for the client
and the server boots cleanly with `ensureSchema()` applying the migration.
---
## 9. File-change map (reference)
| Area | Files |
|---|---|
| Schema/seed | `server/db/schema.sql`, `server/db/seed.js`, `server/src/utils/db.js` (ensureSchema) |
| Models | `server/src/model/wiki/wiki.db.js`, `wiki.model.js`, **new** `wiki.links.js` |
| API | `server/src/router/v1/public/public.{routes,controller}.js`, `server/src/router/v1/admin/admin.{routes,controller}.js` |
| Sanitize | **new** `server/src/utils/sanitizeHtml.js` |
| Client API | `client/src/api/client.js` |
| Public UI | `client/src/routes/wiki/Wiki.jsx`, `WikiArticle.jsx` |
| Admin UI | `client/src/routes/admin/views/WikiAdmin.jsx`, `WikiEditor.jsx`, **new** category manager + revisions view |
| Deps | `client/package.json`, `server/package.json` |
---
## 10. Open questions / assumptions
1. **Slug redirects**: assumed not needed on rename (staff wiki). Revisit if pages get
external inbound links.
2. **Search ranking**: FULLTEXT natural-language mode assumed; can switch to BOOLEAN
mode if operators are wanted later.
3. **Diff granularity**: line/word diff of the HTML source is assumed sufficient for
revision compare; a rendered visual diff is a later nice-to-have.
4. **Editor scope**: tables and embeds beyond images are deferred unless requested.

View File

@@ -1,4 +1,4 @@
# ─── UOMysticmoon Discord bot — local dev environment ─── # ─── Runic Gateway Discord bot — local dev environment ───
# Copy to bot/.env for running `npm run dev` outside Docker. # Copy to bot/.env for running `npm run dev` outside Docker.
# (In Docker, the root .env / docker-compose provides these instead.) # (In Docker, the root .env / docker-compose provides these instead.)
# #
@@ -40,6 +40,6 @@ SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
# etc.) directly. Point this at the same DB the server/ uses. # etc.) directly. Point this at the same DB the server/ uses.
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
DB_NAME=uomysticmoon DB_NAME=runic_gateway
DB_USER=uomm DB_USER=runic
DB_PASSWORD=change-me-db-password DB_PASSWORD=change-me-db-password

4
bot/package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "uomysticmoon-bot", "name": "runic-gateway-bot",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "uomysticmoon-bot", "name": "runic-gateway-bot",
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {

View File

@@ -1,7 +1,7 @@
{ {
"name": "uomysticmoon-bot", "name": "runic-gateway-bot",
"version": "1.0.0", "version": "1.0.0",
"description": "Discord bot for the UOMysticmoon community server", "description": "Discord bot for the Runic Gateway community server",
"private": true, "private": true,
"main": "src/server.js", "main": "src/server.js",
"scripts": { "scripts": {

View File

@@ -4,6 +4,9 @@ const internalRouter = require('./internal/internal.routes')
const app = express() const app = express()
// Internal-only listener, but don't advertise the stack anyway (defense in depth).
app.disable('x-powered-by')
app.use(express.json()) app.use(express.json())
app.get('/health', (req, res) => res.json({ status: 'ok' })) app.get('/health', (req, res) => res.json({ status: 'ok' }))

62
bot/src/bootstrap.js vendored
View File

@@ -4,11 +4,50 @@
// container restart (crash, `docker compose restart`, host reboot) self-heals // container restart (crash, `docker compose restart`, host reboot) self-heals
// without any admin-panel interaction. Node 20's built-in fetch is used; no // without any admin-panel interaction. Node 20's built-in fetch is used; no
// extra HTTP client dependency needed for a single startup call. // extra HTTP client dependency needed for a single startup call.
//
// The fetch RETRIES with backoff: on `docker compose up`, the bot and the app
// start together and the bot's `depends_on: app` only waits for the container
// to *start*, not for the app's internal server to be listening (it still has
// to reach the DB and boot Express). Without retries the very first fetch loses
// that race, bootstrap gives up, and the bot sits disconnected while the DB
// still says enabled — the exact "enabled but disconnected until I toggle it"
// bug. Retrying until the site answers makes a cold whole-stack start heal on
// its own.
const discordManager = require('./discord/discordManager') const discordManager = require('./discord/discordManager')
const createLogger = require('./utils/logger') const createLogger = require('./utils/logger')
const log = createLogger('bootstrap') const log = createLogger('bootstrap')
const MAX_ATTEMPTS = 30 // ~30 tries * ~2s ≈ 1 min of patience for the app to come up
const RETRY_DELAY_MS = 2000
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// Fetch config from the site, retrying while the site is unreachable or not yet
// ready (network error or 5xx). Returns the parsed config, or null if we gave
// up after MAX_ATTEMPTS. A 4xx (e.g. bad internal key) is a real misconfig, not
// a transient startup race, so we don't retry those.
async function fetchConfig(siteUrl, key) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
if (res.ok) return await res.json()
if (res.status >= 400 && res.status < 500) {
log.error('boot-time config fetch rejected — not retrying', { status: res.status })
return null
}
log.warn('boot-time config fetch not ready — retrying', { status: res.status, attempt })
} catch (err) {
log.warn('boot-time config fetch errored — retrying', { message: err.message, attempt })
}
if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS)
}
log.error('boot-time config fetch gave up after retries — staying disconnected until the admin panel pushes config', {
attempts: MAX_ATTEMPTS,
})
return null
}
async function bootstrap() { async function bootstrap() {
const siteUrl = process.env.SITE_INTERNAL_URL const siteUrl = process.env.SITE_INTERNAL_URL
const key = process.env.BOT_INTERNAL_KEY const key = process.env.BOT_INTERNAL_KEY
@@ -17,21 +56,18 @@ async function bootstrap() {
return return
} }
try { const config = await fetchConfig(siteUrl, key)
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } }) if (!config) return
if (!res.ok) {
log.error('boot-time config fetch failed', { status: res.status }) if (config.enabled) {
return log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
} try {
const config = await res.json()
if (config.enabled) {
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
await discordManager.start({ token: config.token, guildId: config.guildId }) await discordManager.start({ token: config.token, guildId: config.guildId })
} else { } catch (err) {
log.info('boot-time config says disabled — staying disconnected') log.error('boot-time reconnect failed', { message: err.message })
} }
} catch (err) { } else {
log.error('boot-time config fetch errored', { message: err.message }) log.info('boot-time config says disabled — staying disconnected')
} }
} }

13
bot/src/brand.js Normal file
View File

@@ -0,0 +1,13 @@
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
// logs carry the instance identity. Kept minimal — the bot only needs the name
// and the accent color (as an int for discord.js embeds).
require('dotenv').config()
const name = process.env.BRAND_NAME || 'Runic Gateway'
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
const accentInt = (() => {
const n = parseInt(String(accentHex).replace('#', ''), 16)
return Number.isNaN(n) ? 0x7f99bd : n
})()
module.exports = { name, accentHex, accentInt }

View File

@@ -12,7 +12,7 @@ const pool = mariadb.createPool({
port: Number(process.env.DB_PORT) || 3306, port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root', user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '', password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'uomysticmoon', database: process.env.DB_NAME || 'runic_gateway',
connectionLimit: 5, connectionLimit: 5,
insertIdAsNumber: true, insertIdAsNumber: true,
bigIntAsNumber: true, bigIntAsNumber: true,

View File

@@ -4,6 +4,46 @@ const guildConfig = require('../../model/guildConfig')
const inviteLog = require('../../model/inviteLog') const inviteLog = require('../../model/inviteLog')
const inviteRotator = require('../../invites/inviteRotator') const inviteRotator = require('../../invites/inviteRotator')
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
async function handleChannel(interaction) {
const channel = interaction.options.getChannel('channel')
if (!channel) {
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
await interaction.reply({ content, ephemeral: true })
return
}
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
}
async function handleRotate(interaction) {
await interaction.deferReply({ ephemeral: true })
try {
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
triggeredBy: interaction.user.id,
triggeredByTag: interaction.user.tag,
})
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
} catch (err) {
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
}
}
async function handleLog(interaction) {
const rows = await inviteLog.list(interaction.guildId, 10)
if (rows.length === 0) {
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
return
}
const lines = rows.map((r) => {
const who = r.triggered_by_tag || 'automatic (scheduled)'
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
module.exports = { module.exports = {
data: { data: {
name: 'invite', name: 'invite',
@@ -40,46 +80,8 @@ module.exports = {
}, },
async execute(interaction) { async execute(interaction) {
const sub = interaction.options.getSubcommand() const sub = interaction.options.getSubcommand()
if (sub === 'channel') return handleChannel(interaction)
if (sub === 'channel') { if (sub === 'rotate') return handleRotate(interaction)
const channel = interaction.options.getChannel('channel') if (sub === 'log') return handleLog(interaction)
if (!channel) {
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
await interaction.reply({ content, ephemeral: true })
return
}
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
return
}
if (sub === 'rotate') {
await interaction.deferReply({ ephemeral: true })
try {
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
triggeredBy: interaction.user.id,
triggeredByTag: interaction.user.tag,
})
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
} catch (err) {
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
}
return
}
if (sub === 'log') {
const rows = await inviteLog.list(interaction.guildId, 10)
if (rows.length === 0) {
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
return
}
const lines = rows.map((r) => {
const who = r.triggered_by_tag || 'automatic (scheduled)'
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
}, },
} }

View File

@@ -9,6 +9,7 @@ const {
} = require('discord.js') } = require('discord.js')
const roleMenus = require('../../model/roleMenus') const roleMenus = require('../../model/roleMenus')
const brand = require('../../brand')
// Capped at 5 roles per menu — a single Discord action row holds at most 5 // Capped at 5 roles per menu — a single Discord action row holds at most 5
// buttons, and one row keeps this a single simple slash command instead of // buttons, and one row keeps this a single simple slash command instead of
@@ -62,7 +63,7 @@ module.exports = {
return return
} }
const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2) const embed = new EmbedBuilder().setTitle(title).setColor(brand.accentInt)
if (description) embed.setDescription(description) if (description) embed.setDescription(description)
const row = new ActionRowBuilder().addComponents( const row = new ActionRowBuilder().addComponents(

View File

@@ -5,6 +5,72 @@ const scheduledMessages = require('../../model/scheduledMessages')
const scheduler = require('../../scheduler/scheduler') const scheduler = require('../../scheduler/scheduler')
const { parseDuration } = require('../../utils/duration') const { parseDuration } = require('../../utils/duration')
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
async function handleRecurring(interaction) {
const channel = interaction.options.getChannel('channel', true)
const cronExpr = interaction.options.getString('cron', true)
const message = interaction.options.getString('message', true)
if (!cron.validate(cronExpr)) {
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
return
}
const id = await scheduledMessages.addRecurring({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
cronExpression: cronExpr,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await scheduler.refresh()
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
}
async function handleOnce(interaction) {
const channel = interaction.options.getChannel('channel', true)
const inInput = interaction.options.getString('in', true)
const message = interaction.options.getString('message', true)
const ms = parseDuration(inInput)
if (!ms) {
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
return
}
const runAt = new Date(Date.now() + ms)
const id = await scheduledMessages.addOnce({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
runAt,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
}
async function handleRemove(interaction) {
const id = interaction.options.getInteger('id', true)
const removed = await scheduledMessages.remove(interaction.guildId, id)
await scheduler.refresh()
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
}
async function handleList(interaction) {
const rows = await scheduledMessages.list(interaction.guildId)
if (rows.length === 0) {
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
return
}
const lines = rows.map((r) => {
let kind
if (r.cron_expression) kind = `cron \`${r.cron_expression}\``
else if (r.sent_at) kind = `sent ${new Date(r.sent_at).toLocaleString()}`
else kind = `due ${new Date(r.run_at).toLocaleString()}`
const suffix = r.enabled ? '' : ' (disabled)'
return `**#${r.id}** <#${r.channel_id}> — ${kind}${suffix}`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
module.exports = { module.exports = {
data: { data: {
name: 'schedule', name: 'schedule',
@@ -47,73 +113,9 @@ module.exports = {
}, },
async execute(interaction) { async execute(interaction) {
const sub = interaction.options.getSubcommand() const sub = interaction.options.getSubcommand()
if (sub === 'recurring') return handleRecurring(interaction)
if (sub === 'recurring') { if (sub === 'once') return handleOnce(interaction)
const channel = interaction.options.getChannel('channel', true) if (sub === 'remove') return handleRemove(interaction)
const cronExpr = interaction.options.getString('cron', true) if (sub === 'list') return handleList(interaction)
const message = interaction.options.getString('message', true)
if (!cron.validate(cronExpr)) {
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
return
}
const id = await scheduledMessages.addRecurring({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
cronExpression: cronExpr,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await scheduler.refresh()
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
return
}
if (sub === 'once') {
const channel = interaction.options.getChannel('channel', true)
const inInput = interaction.options.getString('in', true)
const message = interaction.options.getString('message', true)
const ms = parseDuration(inInput)
if (!ms) {
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
return
}
const runAt = new Date(Date.now() + ms)
const id = await scheduledMessages.addOnce({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
runAt,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
return
}
if (sub === 'remove') {
const id = interaction.options.getInteger('id', true)
const removed = await scheduledMessages.remove(interaction.guildId, id)
await scheduler.refresh()
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
return
}
if (sub === 'list') {
const rows = await scheduledMessages.list(interaction.guildId)
if (rows.length === 0) {
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
return
}
const lines = rows.map((r) => {
const kind = r.cron_expression
? `cron \`${r.cron_expression}\``
: r.sent_at
? `sent ${new Date(r.sent_at).toLocaleString()}`
: `due ${new Date(r.run_at).toLocaleString()}`
return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
}, },
} }

View File

@@ -50,6 +50,42 @@ async function stop() {
log.info('discord client disconnected') log.info('discord client disconnected')
} }
// Post-login startup: register commands and start the background workers. A
// failure here leaves the client connected but flags an error status.
async function onReady() {
try {
await registerCommands(client.application.id, guildId)
await scheduler.start(client)
tempRoleSweeper.start(client)
inviteScheduler.start(client, guildId)
await inviteTracker.prime(client, guildId)
status = 'connected'
statusDetail = null
lastConnectedAt = new Date()
log.info('discord client ready', { user: client.user?.tag, guildId })
} catch (err) {
status = 'error'
statusDetail = `startup failed: ${err.message}`
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
}
}
// Route an interaction: role-menu handler first, then chat-input slash commands.
async function onInteractionCreate(interaction) {
if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
if (!command) return
try {
await command.execute(interaction)
} catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
else await interaction.reply(payload)
}
}
// start({ token, guildId }) — (re)connects. Always stops any existing client // start({ token, guildId }) — (re)connects. Always stops any existing client
// first so re-saving config or toggling Enabled off/on is idempotent. // first so re-saving config or toggling Enabled off/on is idempotent.
async function start({ token, guildId: gid }) { async function start({ token, guildId: gid }) {
@@ -72,39 +108,8 @@ async function start({ token, guildId: gid }) {
], ],
}) })
client.once('ready', async () => { client.once('ready', onReady)
try { client.on('interactionCreate', onInteractionCreate)
await registerCommands(client.application.id, guildId)
await scheduler.start(client)
tempRoleSweeper.start(client)
inviteScheduler.start(client, guildId)
await inviteTracker.prime(client, guildId)
status = 'connected'
statusDetail = null
lastConnectedAt = new Date()
log.info('discord client ready', { user: client.user?.tag, guildId })
} catch (err) {
status = 'error'
statusDetail = `startup failed: ${err.message}`
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
}
})
client.on('interactionCreate', async (interaction) => {
if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
if (!command) return
try {
await command.execute(interaction)
} catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
else await interaction.reply(payload)
}
})
client.on('messageCreate', messageFilter.handleMessageCreate) client.on('messageCreate', messageFilter.handleMessageCreate)
client.on('guildMemberAdd', handleGuildMemberAdd) client.on('guildMemberAdd', handleGuildMemberAdd)
client.on('guildMemberRemove', handleGuildMemberRemove) client.on('guildMemberRemove', handleGuildMemberRemove)

View File

@@ -66,8 +66,7 @@ function detectSpam(message) {
async function isBypassed(message, cache) { async function isBypassed(message, cache) {
if (cache.allowChannels.has(message.channelId)) return true if (cache.allowChannels.has(message.channelId)) return true
const memberRoles = message.member ? message.member.roles.cache : null const memberRoles = message.member ? message.member.roles.cache : null
if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true return Boolean(memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id)))
return false
} }
async function applyWarnAction(message, reason) { async function applyWarnAction(message, reason) {

View File

@@ -43,6 +43,34 @@ async function record({ client, guildId, actionType, target, staffUser, reason,
} }
} }
// Post an "appeal approved → action reversed" embed to the mod-log channel.
// Unlike record() this NEVER inserts a mod_actions row — the reversal is an
// out-of-band correction driven by the site's appeals flow, not a new staff
// action. Best-effort: a missing channel or send failure is logged, not thrown.
async function postReversal({ client, guildId, actionType, discordUserId, appealId }) {
try {
const channelId = await guildConfig.getModLogChannelId(guildId)
if (!channelId) return
const channel = await client.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) return
const reversed = actionType === 'ban' ? 'Ban lifted (unbanned)' : 'Mute cleared (timeout removed)'
const embed = new EmbedBuilder()
.setColor(0x88c0a0)
.setTitle('APPEAL APPROVED')
.addFields(
{ name: 'Action reversed', value: reversed, inline: true },
{ name: 'Target id', value: `${discordUserId}`, inline: true },
{ name: 'Appeal', value: `#${appealId}`, inline: true },
)
.setTimestamp()
await channel.send({ embeds: [embed] })
} catch (err) {
log.warn('failed to post appeal-reversal embed', { message: err.message })
}
}
function formatDuration(seconds) { function formatDuration(seconds) {
if (seconds % 86400 === 0) return `${seconds / 86400}d` if (seconds % 86400 === 0) return `${seconds / 86400}d`
if (seconds % 3600 === 0) return `${seconds / 3600}h` if (seconds % 3600 === 0) return `${seconds / 3600}h`
@@ -50,4 +78,4 @@ function formatDuration(seconds) {
return `${seconds}s` return `${seconds}s`
} }
module.exports = { record } module.exports = { record, postReversal }

View File

@@ -4,6 +4,7 @@
const { EmbedBuilder } = require('discord.js') const { EmbedBuilder } = require('discord.js')
const guildConfig = require('../model/guildConfig') const guildConfig = require('../model/guildConfig')
const brand = require('../brand')
const createLogger = require('../utils/logger') const createLogger = require('../utils/logger')
const log = createLogger('news') const log = createLogger('news')
@@ -15,7 +16,7 @@ async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl })
const channel = await client.channels.fetch(channelId) const channel = await client.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.') if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
const embed = new EmbedBuilder().setColor(0x6a8fc2).setTitle(title).setURL(url) const embed = new EmbedBuilder().setColor(brand.accentInt).setTitle(title).setURL(url)
if (excerpt) embed.setDescription(excerpt) if (excerpt) embed.setDescription(excerpt)
if (imageUrl) embed.setImage(imageUrl) if (imageUrl) embed.setImage(imageUrl)

View File

@@ -2,7 +2,7 @@
// current guild (anti-raid/anti-advertising). An invite that fails to resolve // current guild (anti-raid/anti-advertising). An invite that fails to resolve
// (expired/invalid/vanity-only) is treated as foreign too — safer default // (expired/invalid/vanity-only) is treated as foreign too — safer default
// than silently letting an unresolvable link through. // than silently letting an unresolvable link through.
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-z0-9-]+)/gi
// Returns the first foreign (or unresolvable) invite code found in the message, // Returns the first foreign (or unresolvable) invite code found in the message,
// or null if the message contains no foreign invites. Returning the code (rather // or null if the message contains no foreign invites. Returning the code (rather

View File

@@ -1,9 +1,14 @@
const discordManager = require('../discord/discordManager') const discordManager = require('../discord/discordManager')
const newsAnnounce = require('../discord/newsAnnounce') const newsAnnounce = require('../discord/newsAnnounce')
const modLog = require('../discord/modLog')
const createLogger = require('../utils/logger') const createLogger = require('../utils/logger')
const log = createLogger('internal') const log = createLogger('internal')
const REVERSIBLE = new Set(['ban', 'mute'])
// discord.js REST error code for removing a ban that no longer exists.
const UNKNOWN_BAN = 10026
// POST /internal/config — called by the main server right after an admin // POST /internal/config — called by the main server right after an admin
// saves the Discord Bot panel, and by the bot's own bootstrap on startup // saves the Discord Bot panel, and by the bot's own bootstrap on startup
// (via a GET to the server for the current config, then this same start/stop // (via a GET to the server for the current config, then this same start/stop
@@ -47,4 +52,51 @@ async function announce(req, res) {
} }
} }
module.exports = { setConfig, getStatus: getStatusHandler, announce } // POST /internal/mod-reverse — called by the main server when a staffer APPROVES
// a moderation appeal (Phase 6d). Body: { discord_user_id, action_type, appeal_id }.
// Reverses the Discord action: 'ban' → lift the ban, 'mute' → clear the timeout.
// Idempotent-friendly: an already-lifted ban ("Unknown Ban") or a member who has
// left the guild is treated as success (the desired end state already holds).
async function reverseModAction(req, res) {
const { discord_user_id: discordUserId, action_type: actionType, appeal_id: appealId } = req.body || {}
if (!REVERSIBLE.has(actionType)) {
return res.status(400).json({ message: 'action_type must be ban or mute' })
}
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
const reason = `Appeal #${appealId} approved`
try {
const guild = await connection.client.guilds.fetch(connection.guildId)
if (actionType === 'ban') {
try {
await guild.bans.remove(discordUserId, reason)
} catch (err) {
// Unknown Ban → already unbanned; anything else is a real failure.
if (err.code !== UNKNOWN_BAN) throw err
}
} else {
// mute: clear the timeout. If the member has left, there's nothing to clear.
const member = await guild.members.fetch(discordUserId).catch(() => null)
if (member) await member.timeout(null, reason)
}
await modLog.postReversal({
client: connection.client,
guildId: connection.guildId,
actionType,
discordUserId,
appealId,
})
return res.json({ reversed: true })
} catch (err) {
log.error('mod-reverse failed', { message: err.message, actionType, discordUserId })
return res.status(500).json({ message: err.message })
}
}
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }

View File

@@ -10,5 +10,6 @@ router.use(requireInternalKey)
router.post('/config', ctrl.setConfig) router.post('/config', ctrl.setConfig)
router.get('/status', ctrl.getStatus) router.get('/status', ctrl.getStatus)
router.post('/announce', ctrl.announce) router.post('/announce', ctrl.announce)
router.post('/mod-reverse', ctrl.reverseModAction)
module.exports = router module.exports = router

View File

@@ -1,4 +1,4 @@
// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon // Gate for the bot's /internal/* API. The only caller is the main Runic Gateway
// server, over the private compose network — never expose this route through // server, over the private compose network — never expose this route through
// the public reverse proxy. Timing-safe compare so response time can't be used // the public reverse proxy. Timing-safe compare so response time can't be used
// to brute-force the shared secret one byte at a time. // to brute-force the shared secret one byte at a time.

View File

@@ -4,6 +4,7 @@ const app = require('./app')
const bootstrap = require('./bootstrap') const bootstrap = require('./bootstrap')
const createLogger = require('./utils/logger') const createLogger = require('./utils/logger')
const discordManager = require('./discord/discordManager') const discordManager = require('./discord/discordManager')
const brand = require('./brand')
const pkg = require('../package.json') const pkg = require('../package.json')
const log = createLogger('server') const log = createLogger('server')
@@ -11,7 +12,7 @@ const PORT = Number(process.env.PORT) || 4100
const HOST = '0.0.0.0' const HOST = '0.0.0.0'
async function start() { async function start() {
log.info(`starting UOMysticmoon bot v${pkg.version}`, { log.info(`starting ${brand.name} bot v${pkg.version}`, {
node: process.version, node: process.version,
logFile: createLogger.logFilePath || 'disabled (console only)', logFile: createLogger.logFilePath || 'disabled (console only)',
}) })

View File

@@ -7,7 +7,7 @@ const MAX_TIMEOUT_MS = 28 * 86_400_000
function parseDuration(input) { function parseDuration(input) {
if (!input) return null if (!input) return null
const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim()) const match = /^(\d+)\s*([smhd])$/i.exec(input.trim())
if (!match) return null if (!match) return null
const [, amount, unit] = match const [, amount, unit] = match
return Number(amount) * UNIT_MS[unit.toLowerCase()] return Number(amount) * UNIT_MS[unit.toLowerCase()]

15
brand/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Brand assets (per-instance)
This directory is bind-mounted into the container at `/app/brand` (see
`docker-compose.yml`). Drop instance branding images here and point the matching
`BRAND_*` env vars at them, e.g.:
```
BRAND_LOGO=/brand/logo.png
BRAND_HERO=/brand/hero.png
BRAND_FAVICON=/brand/favicon.ico
```
Leave the vars blank to use the built-in defaults (the hero falls back to a
neutral built-in image; no logo/favicon is injected). Nothing here is required
for the app to run — it renders cleanly with an empty `brand/`.

View File

@@ -3,8 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UOMysticmoon</title> <title>Runic Gateway</title>
<meta name="description" content="UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes." /> <meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />

View File

@@ -1,11 +1,11 @@
{ {
"name": "uomysticmoon-client", "name": "runic-gateway-client",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "uomysticmoon-client", "name": "runic-gateway-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tiptap/extension-image": "^2.27.2", "@tiptap/extension-image": "^2.27.2",

View File

@@ -1,12 +1,13 @@
{ {
"name": "uomysticmoon-client", "name": "runic-gateway-client",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-image": "^2.27.2", "@tiptap/extension-image": "^2.27.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -18,6 +18,10 @@ import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx' import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx' import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx' import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Wiki from './routes/wiki/Wiki.jsx' import 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' import CmsPage from './routes/public/CmsPage.jsx'
@@ -36,28 +40,42 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx' import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
import ShardOps from './routes/admin/views/ShardOps.jsx'
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx' import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' 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'
import Appeals from './routes/admin/views/Appeals.jsx'
// Player portal // Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx' import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx' import PlayerRegister from './routes/player/PlayerRegister.jsx'
import ForgotPassword from './routes/player/ForgotPassword.jsx'
import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx' import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx' import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
import PlayerCharacter from './routes/player/PlayerCharacter.jsx' import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
export default function App() { export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<SiteProvider> <SiteProvider>
<Routes> <Routes>
{/* Public site — gated by maintenance mode (admins preview through it) */} {/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
<Route path="/" element={<Portal />} />
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
<Route <Route
element={ element={
<MaintenanceGate> <MaintenanceGate>
@@ -65,7 +83,6 @@ export default function App() {
</MaintenanceGate> </MaintenanceGate>
} }
> >
<Route path="/" element={<Portal />} />
<Route path="/site" element={<Website />} /> <Route path="/site" element={<Website />} />
<Route path="/site/news" element={<News />} /> <Route path="/site/news" element={<News />} />
<Route path="/site/screenshots" element={<Screenshots />} /> <Route path="/site/screenshots" element={<Screenshots />} />
@@ -76,6 +93,10 @@ export default function App() {
<Route path="/site/status" element={<Status />} /> <Route path="/site/status" element={<Status />} />
<Route path="/site/shard" element={<Shard />} /> <Route path="/site/shard" element={<Shard />} />
<Route path="/site/shard/activity" element={<ShardActivity />} /> <Route path="/site/shard/activity" element={<ShardActivity />} />
<Route path="/site/champs" element={<ChampSpawns />} />
<Route path="/site/guilds" element={<Guilds />} />
<Route path="/site/governors" element={<Governors />} />
<Route path="/site/houses" element={<Houses />} />
<Route path="/wiki" element={<Wiki />} /> <Route path="/wiki" 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 {/* CMS pages: top-level /:slug, matched only after the named routes
@@ -115,15 +136,34 @@ export default function App() {
> >
<Route index element={<Moderation />} /> <Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} /> <Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} />
</Route> </Route>
<Route path="activity" element={<ActivityAdmin />} /> <Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} /> <Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} /> <Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="shard" element={<ShardAdmin />} /> <Route path="shard" element={<ShardAdmin />} />
<Route
path="shard-ops"
element={
<RoleGate roles={['admin', 'moderator']}>
<ShardOps />
</RoleGate>
}
/>
<Route
path="houses"
element={
<RoleGate roles={['admin', 'moderator']}>
<HousesAdmin />
</RoleGate>
}
/>
<Route path="characters" element={<AdminCharacters />} /> <Route path="characters" element={<AdminCharacters />} />
<Route path="characters/:serial" element={<AdminCharacter />} /> <Route path="characters/:serial" element={<AdminCharacter />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} /> <Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} /> <Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} /> <Route path="account" element={<AccountAdmin />} />
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />
</Route> </Route>
@@ -131,6 +171,9 @@ export default function App() {
{/* Player portal */} {/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} /> <Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} /> <Route path="/account/register" element={<PlayerRegister />} />
<Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
<Route <Route
element={ element={
<RequirePlayer> <RequirePlayer>
@@ -141,6 +184,7 @@ export default function App() {
<Route path="/player" element={<PlayerCharacters />} /> <Route path="/player" element={<PlayerCharacters />} />
<Route path="/player/char/:serial" element={<PlayerCharacter />} /> <Route path="/player/char/:serial" element={<PlayerCharacter />} />
<Route path="/account" element={<PlayerAccount />} /> <Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -2,6 +2,10 @@
// same-origin API (/api/v1) — proxied to the Express server in dev. // same-origin API (/api/v1) — proxied to the Express server in dev.
const BASE = '/api/v1' const BASE = '/api/v1'
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
// callers can append it to a path without a dangling "?".
const withQs = (s) => (s ? `?${s}` : '')
class ApiError extends Error { class ApiError extends Error {
constructor(status, message, body) { constructor(status, message, body) {
super(message) super(message)
@@ -48,14 +52,48 @@ export const api = {
// optional email. Returns { user } and sets the session cookie on success. // optional email. Returns { user } and sets the session cookie on success.
register: (username, password, extra = {}) => register: (username, password, extra = {}) =>
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }), req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) => // Email invites (public, token-gated accept).
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }), getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
acceptInvite: (token, username, password, extra = {}) =>
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
// Second factor for web login. `extra` carries the optional recoveryCode (an
// alternative to code) and the trustDevice/deviceName opt-in. On success the
// response may include { trustLimitReached, devices } when trust was requested
// but the device cap is reached.
loginTotp: (challenge, code, extra = {}) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
// Self-service password reset (public, token-gated). forgot always resolves the
// same way whether or not the email exists (no enumeration); getPasswordReset
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword
// sets the new password and revokes all sessions (the user then signs in fresh).
forgotPassword: (email) => req('/auth/password/forgot', { method: 'POST', body: { email } }),
getPasswordReset: (token) => req(`/auth/password/reset/${encodeURIComponent(token)}`),
resetPassword: (token, password) =>
req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by // Second factor for an SSO login (challenge is held in an httpOnly cookie set by
// the callback, so only the code is sent). Returns { user, returnTo }. // the callback, so only the code is sent). Returns { user, returnTo }.
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }), ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
logout: () => req('/auth/logout', { method: 'POST' }), logout: () => req('/auth/logout', { method: 'POST' }),
// Public SSO provider discovery — drives the login-page provider buttons. // Public SSO provider discovery — drives the login-page provider buttons.
authProviders: () => req('/auth/providers'), authProviders: () => req('/auth/providers'),
// Active mobile device sessions (role-agnostic self-service under /auth/me).
// List the active ones and revoke a single device by its session id.
mySessions: () => req('/auth/me/sessions'),
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
// are the browsers/apps allowed to skip the TOTP step at login (distinct from
// mySessions, which are live mobile login sessions).
myTrustedDevices: () => req('/auth/me/trusted-devices'),
trustThisDevice: (deviceName) =>
req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
revokeTrustedDevice: (id) =>
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
// returned ONCE (password step-up for accounts that have a password).
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
generateRecoveryCodes: (currentPassword) =>
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
// ----- public ----- // ----- public -----
publicSettings: () => req('/public/settings'), publicSettings: () => req('/public/settings'),
@@ -68,7 +106,7 @@ export const api = {
if (opts.tag) qs.set('tag', opts.tag) if (opts.tag) qs.set('tag', opts.tag)
if (opts.q) qs.set('q', opts.q) if (opts.q) qs.set('q', opts.q)
const s = qs.toString() const s = qs.toString()
return req(`/public/wiki${s ? `?${s}` : ''}`) return req(`/public/wiki${withQs(s)}`)
}, },
wikiCategories: () => req('/public/wiki/categories'), wikiCategories: () => req('/public/wiki/categories'),
wikiTags: () => req('/public/wiki/tags'), wikiTags: () => req('/public/wiki/tags'),
@@ -89,11 +127,24 @@ export const api = {
if (opts.kind) qs.set('kind', opts.kind) if (opts.kind) qs.set('kind', opts.kind)
if (opts.limit) qs.set('limit', opts.limit) if (opts.limit) qs.set('limit', opts.limit)
const s = qs.toString() const s = qs.toString()
return req(`/public/shard/feed${s ? `?${s}` : ''}`) return req(`/public/shard/feed${withQs(s)}`)
},
economy: (limit) => {
const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/economy${withQs(q)}`)
}, },
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'), online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'), idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) => {
const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
},
presence: () => req('/public/shard/presence'),
houses: () => req('/public/shard/houses'),
}, },
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
// fetch-only, so SSE subscribers build the URL from here. The admin stream // fetch-only, so SSE subscribers build the URL from here. The admin stream
@@ -105,7 +156,10 @@ export const api = {
admin: { admin: {
dashboard: () => req('/admin/dashboard'), dashboard: () => req('/admin/dashboard'),
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }), setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`), listPosts: (category) => {
const q = category ? `category=${category}` : ''
return req(`/admin/posts${withQs(q)}`)
},
getPost: (id) => req(`/admin/posts/${id}`), getPost: (id) => req(`/admin/posts/${id}`),
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }), createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }), updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
@@ -159,9 +213,37 @@ export const api = {
botActivity: () => req('/admin/bot-activity'), botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }), unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
listUsers: () => req('/admin/users'), listUsers: () => req('/admin/users'),
getUser: (id) => req(`/admin/users/${id}`),
createUser: (data) => req('/admin/users', { method: 'POST', body: data }), createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }), deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// A user's trusted devices + MFA reset (admin only).
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
revokeUserTrustedDevice: (id, deviceId) =>
req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
revokeAllUserTrustedDevices: (id) =>
req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
// Email invites.
listInvites: () => req('/admin/invites'),
createInvite: (email, role, sendEmail = true) =>
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
// account) so the shared GameAccounts component works unchanged.
userShard: (id) => ({
accounts: () => req(`/admin/users/${id}/shard/accounts`),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req(`/admin/users/${id}/shard/sales`),
houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`),
standing: () => req(`/admin/users/${id}/shard/standing`),
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
}),
// ----- moderation dashboard (admin + moderator) ----- // ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'), modSummary: () => req('/admin/moderation/stats/summary'),
@@ -171,7 +253,7 @@ export const api = {
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`) return req(`/admin/moderation/recent${withQs(s)}`)
}, },
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`), modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
modMembers: (params = {}) => { modMembers: (params = {}) => {
@@ -180,21 +262,21 @@ export const api = {
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/members${s ? `?${s}` : ''}`) return req(`/admin/moderation/members${withQs(s)}`)
}, },
modFilterHits: (params = {}) => { modFilterHits: (params = {}) => {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`) return req(`/admin/moderation/filter-hits${withQs(s)}`)
}, },
modSpamHits: (params = {}) => { modSpamHits: (params = {}) => {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`) return req(`/admin/moderation/spam-hits${withQs(s)}`)
}, },
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`), modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
modUserActions: (discordId, params = {}) => { modUserActions: (discordId, params = {}) => {
@@ -203,12 +285,27 @@ export const api = {
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`) return req(`/admin/moderation/user/${discordId}/actions${withQs(s)}`)
}, },
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`), modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
addModNote: (discordId, data) => addModNote: (discordId, data) =>
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }), req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
// ----- moderation appeals (admin + moderator) -----
getAppeals: (params = {}) => {
const qs = new URLSearchParams()
if (params.status) qs.set('status', params.status)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/appeals${withQs(s)}`)
},
getAppeal: (id) => req(`/admin/moderation/appeals/${id}`),
claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }),
resolveAppeal: (id, data) =>
req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),
// ----- account security (self-service 2FA) ----- // ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'), getAccount: () => req('/admin/account'),
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }), totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
@@ -227,6 +324,9 @@ export const api = {
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`), vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`), char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/admin/shard/sales'), sales: () => req('/admin/shard/sales'),
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
createAccount: (account, password) =>
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
}, },
// ----- auth providers / SSO config (admin only) ----- // ----- auth providers / SSO config (admin only) -----
@@ -245,6 +345,20 @@ export const api = {
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }), postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
// `actor` is stamped server-side from the session — never sent from here.
shardOps: {
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
pages: () => req('/admin/shard/pages'),
respondPage: (id, data) =>
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
},
// ----- Email delivery / Gmail OAuth2 (admin only) ----- // ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'), getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }), saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
@@ -276,7 +390,16 @@ export const api = {
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`), char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/player/shard/sales'), sales: () => req('/player/shard/sales'),
houses: () => req('/player/shard/houses'), // the caller's own houses
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
}, },
// ----- moderation appeals (self-service) -----
getMyAppeals: () => req('/player/appeals'),
getEligibleAppeals: () => req('/player/appeals/eligible'),
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
}, },
} }

View File

@@ -1,9 +1,47 @@
// Reusable character-sheet renderer for the char.profile shape returned by // Reusable character-sheet renderer for the char.profile shape returned by
// /public/shard/char/:serial. Presentational only — the parent handles loading // /public/shard/char/:serial. Presentational only — the parent handles loading
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles). // and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
//
// `moderation` opts in the in-game kick/ban controls for the character's account;
// they self-gate to staff (ShardAccountActions), so passing it from a page a
// player can reach is safe.
import ShardAccountActions from './ShardAccountActions.jsx'
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' } const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
// literal string. Without a cliloc table on the site we can only show literals, so
// numeric reward entries are skipped rather than shown as a raw number. Returns a
// de-duped list of human-readable title chips.
function displayTitles(titles) {
if (!titles) return []
const out = []
if (titles.fameKarma) out.push(titles.fameKarma)
if (titles.skill) out.push(titles.skill)
const reward = Array.isArray(titles.reward) ? titles.reward : []
const sel = typeof titles.selected === 'number' ? titles.selected : -1
// Prefer the selected reward title; fall back to the first literal one.
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
return [...new Set(out.filter(Boolean))]
}
function TitleChip({ children, tone = 'var(--muted)' }) {
return (
<span
className="sans"
style={{
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
}}
>
{children}
</span>
)
}
function StatTile({ value, label }) { function StatTile({ value, label }) {
return ( return (
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}> <div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
@@ -28,7 +66,7 @@ function Vital({ label, cur, max }) {
) )
} }
export default function CharacterSheet({ char }) { export default function CharacterSheet({ char, moderation = false }) {
if (!char) return null if (!char) return null
const stats = char.stats || {} const stats = char.stats || {}
const resist = stats.resist || {} const resist = stats.resist || {}
@@ -58,6 +96,29 @@ export default function CharacterSheet({ char }) {
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span> <span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div> </div>
{/* Titles + standing (guild led / governorship) — all optional */}
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
{char.governorOf && char.governorOf.map((city) => (
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
))}
{char.guild && (
<TitleChip tone="var(--accent)">
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
</TitleChip>
)}
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
</div>
)}
{/* Staff moderation for this character's account (self-gates to staff). */}
{moderation && char.acct && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
<ShardAccountActions account={char.acct} />
</div>
)}
{/* Core stats */} {/* Core stats */}
<section> <section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div> <div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>

View File

@@ -20,6 +20,25 @@ function Tile({ value, label }) {
) )
} }
// Fold the settled roster results into totals. `complete` is false when any
// account's roster failed (a partial result — shown as a dash rather than a
// misleadingly low count).
function summarizeRosters(rosters) {
let chars = 0
let online = 0
let complete = true
for (const r of rosters) {
if (r.status !== 'fulfilled') {
complete = false
continue
}
const cs = r.value.chars || []
chars += cs.length
online += cs.filter((c) => c.online).length
}
return { chars, online, complete }
}
export default function CharacterStats({ scope }) { export default function CharacterStats({ scope }) {
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
@@ -36,19 +55,7 @@ export default function CharacterStats({ scope }) {
// Roster is a live round-trip and can be unavailable (503); tolerate a // Roster is a live round-trip and can be unavailable (503); tolerate a
// partial result so a restarting shard doesn't blank the whole row. // partial result so a restarting shard doesn't blank the whole row.
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account))) const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
let chars = 0 if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
let online = 0
let complete = true
for (const r of rosters) {
if (r.status === 'fulfilled') {
const cs = r.value.chars || []
chars += cs.length
online += cs.filter((c) => c.online).length
} else {
complete = false
}
}
if (!cancelled) setStats({ linked, chars, online, complete })
} catch { } catch {
if (!cancelled) setStats({ error: true }) if (!cancelled) setStats({ error: true })
} }

View File

@@ -0,0 +1,69 @@
import { useState } from 'react'
// Reusable "create a game account" form (its own username + password — the game
// client credentials, distinct from the website login). Calls `submit(account,
// password)` which should POST /player/shard/account; on success calls onCreated.
// Used by the player portal (self-serve) and the invite-accept page alike.
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
const [account, setAccount] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function onSubmit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
return setError('Account name must be 330 letters, numbers, . _ or -.')
}
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await submit(account, password)
setMsg(`Game account “${account}” created and linked.`)
setAccount(''); setPassword('')
if (onCreated) await onCreated()
} catch (err) {
if (err.status === 409) setError('That account name is already taken.')
else if (err.status === 429) setError('The account limit for your network has been reached.')
else if (err.status === 403) setError('Game-account signup is not available right now.')
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
else setError(err.message || 'Could not create the account right now.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit}>
{!compact && (
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose the username and password youll type into the game client. These are your
<strong style={{ color: 'var(--head)' }}> game</strong> credentials separate from your website login.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">Game account name</span>
<input
type="text" autoComplete="off" value={account}
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
/>
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Game password</span>
<input
type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} className="input"
/>
</label>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : 'Create game account'}
</button>
</form>
)
}

View File

@@ -1,11 +1,16 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Loading, ErrorState } from './PageState.jsx' import { Loading, ErrorState } from './PageState.jsx'
import ShardAccountActions from './ShardAccountActions.jsx'
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
import { api } from '../api/client.js'
// Shared game-account linking + character roster, used by both the player portal // Shared game-account linking + character roster, used by both the player portal
// (/player) and the staff account page (/admin/account). `scope` is the api // (/player) and the staff account page (/admin/account). `scope` is the api
// object with { link, accounts, roster } (player or admin self-service); `charTo` // object with { link, accounts, roster } (player or admin self-service); `charTo`
// maps a serial to the route for that character's sheet. // maps a serial to the route for that character's sheet. `readOnly` drops the
// link forms and self-voice copy for the admin case where staff view *another*
// user's accounts (no `scope.link`) at /admin/users/:id.
function LinkForm({ scope, onLinked, compact }) { function LinkForm({ scope, onLinked, compact }) {
const [code, setCode] = useState('') const [code, setCode] = useState('')
@@ -105,33 +110,91 @@ function AccountRoster({ scope, account, charTo }) {
) )
} }
export default function GameAccounts({ scope, charTo }) { // Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
// then calls onUnlink(account) and reloads. Errors surface inline.
function UnlinkButton({ account, onUnlink }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function go() {
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
setBusy(true); setError('')
try {
await onUnlink(account)
} catch (err) {
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
setError(byStatus[err.status] || err.message || 'Could not unlink.')
setBusy(false)
}
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
</span>
)
}
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
const [accounts, setAccounts] = useState(null) const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('') const [error, setError] = useState('')
// Whether the site currently offers game-account creation (public flag). Only
// relevant for the self-service (non-readOnly) view with a createAccount scope.
const [signupOk, setSignupOk] = useState(false)
const load = useCallback(async () => { const load = useCallback(async () => {
setError('') setError('')
try { try {
setAccounts(await scope.accounts()) setAccounts(await scope.accounts())
} catch { } catch {
setError('Could not load your game accounts.') setError(readOnly ? 'Could not load this users game accounts.' : 'Could not load your game accounts.')
} }
}, [scope]) }, [scope, readOnly])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => {
if (readOnly || !scope.createAccount) return
let active = true
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false }
}, [readOnly, scope])
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
if (error) return <ErrorState message={error} /> if (error) return <ErrorState message={error} />
if (!accounts) return <Loading /> if (!accounts) return <Loading />
// Not linked yet — prompt to link. // No linked accounts. In read-only (admin viewing another user) this is just an
// empty state; otherwise it's the link-your-account prompt.
if (accounts.length === 0) { if (accounts.length === 0) {
if (readOnly) {
return (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
This user has not linked a game account.
</p>
</div>
)
}
return ( return (
<div className="panel" style={{ padding: 22 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div> <div className="panel" style={{ padding: 22 }}>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}> <div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
You havent linked a game account yet. In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a <p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
one-time code, then enter it below to see your characters, stats, skills and vendors here. Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
</p> one-time code, then enter it below to see your characters, stats, skills and vendors here.
<LinkForm scope={scope} onLinked={load} /> </p>
<LinkForm scope={scope} onLinked={load} />
</div>
{canCreate && (
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
</div>
)}
</div> </div>
) )
} }
@@ -141,16 +204,28 @@ export default function GameAccounts({ scope, charTo }) {
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
{accounts.map((a) => ( {accounts.map((a) => (
<section key={a.account}> <section key={a.account}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
{a.account} <div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{a.account}
</div>
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
</div> </div>
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
<AccountRoster scope={scope} account={a.account} charTo={charTo} /> <AccountRoster scope={scope} account={a.account} charTo={charTo} />
</section> </section>
))} ))}
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}> {!readOnly && (
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div> <section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<LinkForm scope={scope} onLinked={load} compact /> <div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
</section> <LinkForm scope={scope} onLinked={load} compact />
{canCreate && (
<div style={{ marginTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
</div>
)}
</section>
)}
</div> </div>
) )
} }

View File

@@ -34,14 +34,15 @@ function TextBlock({ props }) {
const align = props.align || 'center' const align = props.align || 'center'
return ( return (
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}> <div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
{(props.lines || []).map((line, i) => { {(props.lines || []).map((line) => {
const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p' const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p'
const key = `${line.tag}:${(line.text || '').slice(0, 40)}`
// A rich-text line (e.g. the homepage teaser) carries sanitized HTML; // A rich-text line (e.g. the homepage teaser) carries sanitized HTML;
// sanitize again on render as defense in depth. Others render as text. // sanitize again on render as defense in depth. Others render as text.
if (line.html) { if (line.html) {
return ( return (
<Tag <Tag
key={i} key={key}
className="hero-rich" className="hero-rich"
style={lineStyle(line)} style={lineStyle(line)}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(line.text || '') }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(line.text || '') }}
@@ -49,7 +50,7 @@ function TextBlock({ props }) {
) )
} }
return ( return (
<Tag key={i} style={lineStyle(line)}> <Tag key={key} style={lineStyle(line)}>
{line.text} {line.text}
</Tag> </Tag>
) )
@@ -59,11 +60,11 @@ function TextBlock({ props }) {
} }
function Buttons({ props }) { function Buttons({ props }) {
const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center' const justify = { left: 'flex-start', right: 'flex-end' }[props.align] || 'center'
return ( return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}>
{(props.items || []).map((b, i) => ( {(props.items || []).map((b) => (
<Link key={i} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}> <Link key={`${b.to || ''}:${b.label || ''}`} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}>
{b.label} {b.label}
</Link> </Link>
))} ))}
@@ -160,12 +161,7 @@ export default function HeroElement({
children, children,
}) { }) {
const anchor = element.anchor || 'center' const anchor = element.anchor || 'center'
const transform = const transform = { center: 'translate(-50%, -50%)', 'top-right': 'translateX(-100%)' }[anchor]
anchor === 'center'
? 'translate(-50%, -50%)'
: anchor === 'top-right'
? 'translateX(-100%)'
: undefined
// text_block/buttons may set a box width (px); kept within the containing block // text_block/buttons may set a box width (px); kept within the containing block
// (the hero section live, or the editor canvas) with small side gutters. // (the hero section live, or the editor canvas) with small side gutters.
const boxWidth = const boxWidth =

View File

@@ -0,0 +1,84 @@
import { useMemo } from 'react'
import { useAsync } from '../lib/useAsync.js'
import { useShardFeed } from '../lib/useShardFeed.js'
import { bucketize } from '../data/regionBuckets.js'
import { api } from '../api/client.js'
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
// then keeps the total + region breakdown current from the presence.online SSE
// kind. The raw byRegion map is rolled up into display buckets (see
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
const PRESENCE_KINDS = new Set(['presence.online'])
export default function PlayersOnline() {
const { loading, error, data } = useAsync(() => api.shard.presence())
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
// The freshest snapshot wins: the newest buffered presence.online event, else
// the initial fetch.
const snapshot = events[0] || data
const { total, rows } = useMemo(() => {
const count = Number(snapshot?.count) || 0
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
return { total: count, rows: bucketRows }
}, [snapshot])
return (
<section className="panel" style={{ padding: 20 }}>
<div
className="sans"
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
>
<span
style={{
color: 'var(--accent)',
fontSize: '0.7rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
Players online
</span>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
{loading ? '—' : total}
</span>
</div>
{error && (
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
Population is unavailable right now.
</p>
)}
{!loading && !error && (
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
</p>
) : (
rows.map((r) => (
<div
key={r.id}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
fontSize: '0.9rem',
color: 'var(--ink)',
}}
>
<span>{r.label}</span>
{/* tabular figures keep the right-aligned counts in a clean column */}
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
</div>
))
)}
</div>
)}
</section>
)
}

View File

@@ -36,7 +36,7 @@ function AlignIcon({ align }) {
return ( return (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true"> <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) => ( {rows.map(([x1, x2], i) => (
<line key={i} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} /> <line key={`${x1}-${x2}`} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} />
))} ))}
</svg> </svg>
) )

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext.jsx'
import { api } from '../api/client.js'
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
// game account. Reused wherever a linked account or character is shown to staff:
// the admin user-detail account list and the character sheet. Self-gates on role
// (admin/moderator) so it is safe to render inside components that players also
// see — a player never gets the controls, and the API enforces the same gate.
//
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
// confirm with an optional duration + reason before it fires.
export default function ShardAccountActions({ account, style }) {
const { user } = useAuth()
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const [banOpen, setBanOpen] = useState(false)
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
// Only staff who can actually use the write plane see the controls.
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
async function run(label, fn, done) {
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
const n = r && r.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked${sessions}.`
})
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
const ban = () =>
run('ban', () =>
api.admin.shardOps.ban({
account,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
setBanOpen(false)
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned${when}.`
})
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
return (
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
{banOpen && (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (sec, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
<span className="field-label">Reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
</button>
</div>
)}
</div>
)
}

View File

@@ -2,34 +2,50 @@ import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
export default function SiteFooter() { export default function SiteFooter() {
const { contactEmail } = useSite() const { contactEmail, siteTitle } = useSite()
return ( return (
<footer <footer
className="sans" className="sans site-footer"
style={{ style={{
borderTop: '1px solid var(--line)', borderTop: '1px solid var(--line)',
padding: '28px 16px', padding: '28px 16px',
color: 'var(--muted)', color: 'var(--muted)',
textAlign: 'center',
fontSize: '0.9rem', fontSize: '0.9rem',
background: 'rgba(9,13,18,0.6)', background: 'rgba(9,13,18,0.6)',
}} }}
> >
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}> <div className="site-footer-inner">
<span>UOMysticmoon is an independent private shard project.</span> <div className="site-footer-badge">
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}> <img src="/assets/img/runic-emblem.png" alt="" aria-hidden="true" />
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}> <span>
{contactEmail} Powered by
</a> <br />
&nbsp;·&nbsp; <a
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}> href="https://gitea.whitlocktech.com/RunicGateway"
Shard Status target="_blank"
</Link> rel="noopener noreferrer"
&nbsp;·&nbsp; className="site-footer-brand-link"
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}> >
Admin <strong>Runic Gateway</strong>
</Link> </a>
</span> </span>
</div>
<div className="site-footer-info">
<span>{siteTitle} is an independent private shard project.</span>
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{contactEmail}
</a>
&nbsp;·&nbsp;
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Shard Status
</Link>
&nbsp;·&nbsp;
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
Admin
</Link>
</span>
</div>
</div> </div>
</footer> </footer>
) )

View File

@@ -1,6 +1,7 @@
import { Link, NavLink } from 'react-router-dom' import { Link, NavLink } from 'react-router-dom'
import MoonDot from './MoonDot.jsx' import MoonDot from './MoonDot.jsx'
import { useAuth } from '../contexts/AuthContext.jsx' import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
// One consistent top nav for the whole public site. Every page gets the same // One consistent top nav for the whole public site. Every page gets the same
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin). // main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
@@ -12,6 +13,10 @@ const NAV = [
{ label: 'Newsletter', to: '/site/newsletter' }, { label: 'Newsletter', to: '/site/newsletter' },
{ label: 'Wiki', to: '/wiki' }, { label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard' }, { label: 'Shard', to: '/site/shard' },
{ label: 'Champions', to: '/site/champs' },
{ label: 'Guilds', to: '/site/guilds' },
{ label: 'Governors', to: '/site/governors' },
{ label: 'Houses', to: '/site/houses' },
{ label: 'About', to: '/site/about' }, { label: 'About', to: '/site/about' },
] ]
@@ -23,14 +28,13 @@ const linkStyle = ({ isActive }) => ({
export default function SiteHeader() { export default function SiteHeader() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const { siteTitle } = useSite()
// Where the auth entry points: staff → admin, player → portal, else sign in. // Where the auth entry points: staff → admin, player → portal, else sign in.
const account = let account
user && user.role && user.role !== 'player' if (user && user.role && user.role !== 'player') account = { label: 'Admin', to: '/admin' }
? { label: 'Admin', to: '/admin' } else if (user) account = { label: 'My Account', to: '/player' }
: user else account = { label: 'Sign in', to: '/account/login' }
? { label: 'My Account', to: '/player' }
: { label: 'Sign in', to: '/account/login' }
return ( return (
<header <header
@@ -53,7 +57,7 @@ export default function SiteHeader() {
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
> >
<MoonDot /> <MoonDot />
UOMysticmoon {siteTitle}
</Link> </Link>
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}> <nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{NAV.map((l) => ( {NAV.map((l) => (

View File

@@ -26,8 +26,8 @@ export default function VendorSales({ fetchSales }) {
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p> <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
) : ( ) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}> <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{sales.map((s, i) => ( {sales.map((s) => (
<li key={`${s.t}-${i}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}> <li key={`${s.t}-${s.itemType}-${s.price}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} {Number(s.price || 0).toLocaleString()}gp {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} {Number(s.price || 0).toLocaleString()}gp
</span> </span>

View File

@@ -0,0 +1,62 @@
import { useState } from 'react'
// Renders a freshly generated batch of recovery codes ONCE, with copy + download.
// The backend never returns these again, so the copy stresses saving them now.
export default function RecoveryCodesDisplay({ codes, onDone }) {
const [copied, setCopied] = useState(false)
const text = (codes || []).join('\n')
async function copy() {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
/* clipboard blocked — the codes are visible to copy manually */
}
}
function download() {
const blob = new Blob([`${text}\n`], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'recovery-codes.txt'
a.click()
URL.revokeObjectURL(url)
}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 18, marginTop: 8 }}>
<p className="sans" style={{ margin: '0 0 12px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Save these recovery codes somewhere safe. Each can be used <strong>once</strong> to sign in if you
lose your authenticator. <strong>They will not be shown again.</strong>
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: 8,
fontFamily: 'monospace',
fontSize: '0.95rem',
marginBottom: 14,
}}
>
{(codes || []).map((c) => (
<div key={c} style={{ padding: '8px 10px', border: '1px solid var(--line-soft)', borderRadius: 6, letterSpacing: '0.06em', textAlign: 'center', color: 'var(--head)' }}>
{c}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={copy} className="pill">{copied ? 'Copied!' : 'Copy'}</button>
<button onClick={download} className="pill">Download</button>
{onDone && (
<button onClick={onDone} className="btn btn-primary btn-sq" style={{ marginLeft: 'auto' }}>
Ive saved them
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import RecoveryCodesDisplay from './RecoveryCodesDisplay.jsx'
// Self-service recovery (backup) codes. Shows how many remain and lets the user
// regenerate a fresh set (password step-up). Shown only when 2FA is enabled.
// `hasPassword` decides whether the current-password field is required — an
// SSO-only account with no password may regenerate while authenticated.
export default function RecoveryCodesPanel({ hasPassword = true }) {
const [remaining, setRemaining] = useState(null)
const [currentPassword, setCurrentPassword] = useState('')
const [codes, setCodes] = useState(null) // freshly generated batch, shown once
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
const { remaining: n } = await api.recoveryCodesStatus()
setRemaining(n)
} catch {
/* non-fatal — the panel still offers regeneration */
}
}, [])
useEffect(() => {
load()
}, [load])
async function regenerate() {
setBusy(true)
setError('')
try {
const { recoveryCodes } = await api.generateRecoveryCodes(hasPassword ? currentPassword : undefined)
setCodes(recoveryCodes)
setCurrentPassword('')
await load()
} catch (err) {
setError(err.message || 'Could not generate recovery codes.')
} finally {
setBusy(false)
}
}
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Recovery codes
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Single-use codes that let you sign in if you lose your authenticator. Regenerating replaces any
codes you still have.
</p>
{remaining != null && !codes && (
<p className="sans" style={{ color: remaining > 0 ? '#7fd0a4' : '#e0b352', fontSize: '0.86rem' }}>
{remaining > 0 ? `${remaining} unused code${remaining === 1 ? '' : 's'} remaining.` : 'No unused recovery codes left — regenerate a set.'}
</p>
)}
{codes ? (
<RecoveryCodesDisplay codes={codes} onDone={() => setCodes(null)} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 10 }}>
{hasPassword && (
<label style={{ display: 'block', maxWidth: 260 }}>
<span className="field-label">Current password</span>
<input
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="input"
/>
</label>
)}
<div>
<button onClick={regenerate} disabled={busy || (hasPassword && !currentPassword)} className="btn btn-sq">
{busy ? 'Generating…' : 'Generate new codes'}
</button>
</div>
</div>
)}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import { api } from '../../api/client.js'
// Shown when a user tries to trust a device but is already at the trusted-device
// cap. Styled like the TOTP entry flow (centered card on a dim overlay). The user
// MUST revoke at least one existing device before they can continue — there is no
// silent pruning — or they can cancel and leave the device untrusted.
//
// Props:
// devices — the existing trusted devices (from the 409 / trustLimitReached payload)
// onTrusted — called after the current device is successfully trusted (post-revoke)
// onCancel — called when the user backs out without trusting this device
export default function TrustLimitModal({ devices: initialDevices, onTrusted, onCancel }) {
const [devices, setDevices] = useState(initialDevices || [])
const [revokedAny, setRevokedAny] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function revoke(id) {
setBusy(true)
setError('')
try {
await api.revokeTrustedDevice(id)
setDevices((list) => list.filter((d) => d.id !== id))
setRevokedAny(true)
} catch {
setError('Could not revoke that device. Please try again.')
} finally {
setBusy(false)
}
}
async function trustNow() {
setBusy(true)
setError('')
try {
await api.trustThisDevice()
onTrusted?.()
} catch (err) {
// Still at the cap somehow (a race) — surface it and let them revoke more.
if (err.status === 409 && err.body?.devices) {
setDevices(err.body.devices)
setError('Still at the limit — revoke another device.')
} else {
setError('Could not trust this device. Please try again.')
}
} finally {
setBusy(false)
}
}
return (
<div style={overlay} role="dialog" aria-modal="true" aria-label="Trusted-device limit reached">
<div style={card}>
<h2 className="display" style={{ margin: '0 0 8px', fontSize: '1.15rem', color: 'var(--head)' }}>
Trusted-device limit reached
</h2>
<p className="sans" style={{ margin: '0 0 16px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
You can trust up to {Math.max(devices.length, 1)} devices. Revoke one below to make room, then
continue or cancel to leave this device untrusted.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16, maxHeight: 240, overflowY: 'auto' }}>
{devices.map((d) => (
<div key={d.id} style={row}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>
{d.deviceName || d.platform || 'Device'}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</div>
))}
{devices.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>All devices revoked. You can trust this one now.</p>
)}
</div>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.84rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={trustNow} disabled={busy || !revokedAny} className="btn btn-primary btn-sq">
{busy ? 'Working…' : 'Trust this device'}
</button>
<button onClick={onCancel} disabled={busy} className="pill">
Cancel
</button>
</div>
</div>
</div>
)
}
const overlay = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
zIndex: 1000,
}
const card = {
width: '100%',
maxWidth: 460,
background: 'var(--panel, #1a1a1f)',
border: '1px solid var(--line)',
borderRadius: 12,
padding: 24,
}
const row = {
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 14px',
border: '1px solid var(--line)',
borderRadius: 8,
}

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import TrustLimitModal from './TrustLimitModal.jsx'
// Self-service list of the devices allowed to skip the TOTP step at login (MFA
// "Trust this device"). Uses the role-agnostic /auth/me/trusted-devices surface, so
// the same panel serves players and staff. Shown only when 2FA is enabled — trust
// is meaningless without a second factor to skip.
function fmtDate(s) {
if (!s) return '—'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
export default function TrustedDevicesPanel() {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [capModal, setCapModal] = useState(null) // { devices } when the cap is hit
const load = useCallback(async () => {
try {
setDevices(await api.myTrustedDevices())
} catch {
setError('Could not load your trusted devices.')
}
}, [])
useEffect(() => {
load()
}, [load])
async function trustThis() {
setBusy(true)
setMsg('')
setError('')
try {
await api.trustThisDevice()
setMsg('This device is now trusted.')
await load()
} catch (err) {
if (err.status === 409 && err.body?.error === 'trusted_device_limit') {
setCapModal({ devices: err.body.devices || [] })
} else {
setError('Could not trust this device.')
}
} finally {
setBusy(false)
}
}
async function revoke(id) {
setBusy(true)
setMsg('')
setError('')
try {
await api.revokeTrustedDevice(id)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Untrust every device? Each will require the full two-factor step at the next login.')) return
setBusy(true)
setMsg('')
setError('')
try {
await api.revokeAllTrustedDevices()
setMsg('All devices untrusted.')
await load()
} catch {
setError('Could not untrust devices.')
} finally {
setBusy(false)
}
}
if (!devices) return null
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Trusted devices
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Devices youve trusted skip the authenticator step at login (your password is still required).
Revoke any you dont recognize.
</p>
{devices.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{devices.map((d) => (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'} · last used {fmtDate(d.lastUsedAt)} · expires {fmtDate(d.expiresAt)}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</div>
))}
</div>
) : (
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>No trusted devices yet.</p>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={trustThis} disabled={busy} className="btn btn-sq">Trust this device</button>
{devices.length > 0 && (
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Untrust all
</button>
)}
</div>
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
{capModal && (
<TrustLimitModal
devices={capModal.devices}
onTrusted={() => { setCapModal(null); setMsg('This device is now trusted.'); load() }}
onCancel={() => setCapModal(null)}
/>
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react' import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import { api } from '../api/client.js' import { api } from '../api/client.js'
const AuthContext = createContext(null) const AuthContext = createContext(null)
@@ -38,11 +38,14 @@ export function AuthProvider({ children }) {
return data return data
}, []) }, [])
// Step 2 for TOTP users: exchange the challenge + code for a real session. // Step 2 for TOTP users: exchange the challenge + a second factor (TOTP code or a
const loginTotp = useCallback(async (challenge, code) => { // recovery code) for a real session. `extra` carries recoveryCode + the
const data = await api.loginTotp(challenge, code) // trustDevice/deviceName opt-in. Returns the full payload ({ user,
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
const loginTotp = useCallback(async (challenge, code, extra) => {
const data = await api.loginTotp(challenge, code, extra)
setUser(data.user) setUser(data.user)
return data.user return data
}, []) }, [])
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in // Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
@@ -61,8 +64,15 @@ export function AuthProvider({ children }) {
} }
}, []) }, [])
// Memoized so consumers don't re-render on every provider render (the callbacks
// are already stable via useCallback).
const value = useMemo(
() => ({ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }),
[user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh],
)
return ( return (
<AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}> <AuthContext.Provider value={value}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react' import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import { api } from '../api/client.js' import { api } from '../api/client.js'
const SiteContext = createContext(null) const SiteContext = createContext(null)
@@ -23,14 +23,30 @@ export function SiteProvider({ children }) {
refresh() refresh()
}, [refresh]) }, [refresh])
const value = { const brand = useMemo(() => settings.brand || {}, [settings])
settings,
loading, // Apply the instance accent color to the CSS variable the theme is built on,
refresh, // so branding flows to every `var(--accent)` at runtime (no rebuild).
mode: settings.site_mode || 'live', useEffect(() => {
siteTitle: settings.site_title || 'UOMysticmoon', if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
contactEmail: settings.contact_email || 'UOMysticmoon@gmail.com', }, [brand.accent])
}
// Memoized so consumers don't re-render on every provider render (brand is a
// fresh object each render, which would otherwise churn the context value).
const value = useMemo(
() => ({
settings,
loading,
refresh,
brand,
mode: settings.site_mode || 'live',
siteTitle: brand.name || settings.site_title || 'Runic Gateway',
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
contactEmail: brand.contactEmail || settings.contact_email || '',
heroImage: brand.hero || '/assets/img/runic-emblem.png',
}),
[settings, loading, refresh, brand],
)
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider> return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>
} }

View File

@@ -0,0 +1,31 @@
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
// emoji sigil + a ring colour — enough to make the Governors board and the
// governor badge read as distinct "crests" today, swappable for real artwork
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
// public path) onto an entry and update CityCrest to prefer it.
//
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
export const CITY_CRESTS = {
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
}
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
// Look up a crest by the raw city key, tolerating spacing variants
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
export function crestFor(city) {
if (!city) return FALLBACK
const key = String(city).replace(/\s+/g, '')
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
if (crest) return crest
return { ...FALLBACK, label: String(city) }
}

View File

@@ -0,0 +1,72 @@
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
// regions) up into a handful of labelled display buckets for the "Players Online"
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
// membership) and the widget follows. Anything not matched lands in "Wilderness"
// so the bucket counts always reconcile to the true total.
// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped)
// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a
// list rather than one giant alternation regex (simpler to read and retune).
const TOWN_PREFIXES = [
'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia',
'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold',
'wind', 'delucia', 'papua',
]
const normalizeRegion = (r) => String(r).toLowerCase().replace(/['\s]/g, '')
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
// membership. First matching bucket wins; the last bucket is the catch-all.
export const BUCKETS = [
{
id: 'britain',
label: 'Britain',
// Passthrough for the capital + its immediate surrounds.
match: (r) => /^britain/i.test(r),
},
{
id: 'towns',
label: 'Towns',
// The other named cities/towns.
match: (r) => {
const norm = normalizeRegion(r)
return TOWN_PREFIXES.some((t) => norm.startsWith(t))
},
},
{
id: 'dungeons',
label: 'Dungeons',
match: (r) =>
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
r,
),
},
{
id: 'housing',
label: 'Housing',
// House regions expose themselves as named house/townhouse regions.
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
},
{
id: 'wilderness',
label: 'Wilderness',
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
match: () => true,
},
]
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
// order, dropping empty buckets, with the summed total also returned.
export function bucketize(byRegion = {}) {
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
let total = 0
for (const [region, n] of Object.entries(byRegion || {})) {
const count = Number(n) || 0
total += count
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
totals.set(bucket.id, totals.get(bucket.id) + count)
}
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
(r) => r.count > 0,
)
return { rows, total }
}

View File

@@ -1,13 +1,28 @@
// Shared hero-layout helpers used by the public portal and the admin editor. // Shared hero-layout helpers used by the public portal and the admin editor.
export const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png' // Runic Gateway default hero emblem; the instance hero image (BRAND_HERO)
// overrides it at runtime, threaded in as `defaultImage` by the portal.
export const DEFAULT_HERO_IMAGE = '/assets/img/runic-emblem.png'
// The original hand-tuned multi-gradient hero background (used only for the // Default hero background: the emblem centered behind the text as a medallion,
// untouched default so the live page is byte-for-byte unchanged until edited). // under a symmetric dark overlay tuned to keep centered hero copy legible.
export const HERO_BG = // Two layers (overlay gradient + image) so the per-layer background-size in
"linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('" + // `heroBackground` can contain the square emblem while the overlay stays full-bleed.
DEFAULT_HERO_IMAGE + export function heroBgStack(image) {
"')" return (
'linear-gradient(180deg,rgba(11,15,20,0.62) 0%,rgba(11,15,20,0.48) 38%,rgba(11,15,20,0.52) 58%,rgba(11,15,20,0.86) 100%),' +
"url('" +
(image || DEFAULT_HERO_IMAGE) +
"')"
)
}
// Keep the emblem fully visible and centered, capped so it never overflows a
// narrow viewport; the overlay layer covers.
export const HERO_DEFAULT_SIZE = 'cover, min(74vh, 640px, 86vw)'
export const HERO_DEFAULT_POSITION = 'center, center'
export const HERO_BG = heroBgStack(DEFAULT_HERO_IMAGE)
// Single-stop dark overlay driven by the editor's opacity slider. // Single-stop dark overlay driven by the editor's opacity slider.
export function buildOverlay(opacity) { export function buildOverlay(opacity) {
@@ -16,15 +31,23 @@ export function buildOverlay(opacity) {
// Background style for a layout. When `isDefault` and no custom image is set, use // Background style for a layout. When `isDefault` and no custom image is set, use
// the exact original gradient stack; otherwise compose the overlay over the image. // the exact original gradient stack; otherwise compose the overlay over the image.
export function heroBackground(layout, { isDefault = false } = {}) { export function heroBackground(layout, { isDefault = false, defaultImage } = {}) {
const bg = layout.background || {} const bg = layout.background || {}
const backgroundImage = const fallback = defaultImage || DEFAULT_HERO_IMAGE
isDefault && !bg.image_url // Untouched default: emblem contained + centered behind the text (per-layer
? HERO_BG // size/position so the overlay stays full-bleed while the square emblem fits).
: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || DEFAULT_HERO_IMAGE}')` if (isDefault && !bg.image_url) {
return {
backgroundColor: 'var(--bg-deep)',
backgroundImage: heroBgStack(fallback),
backgroundPosition: HERO_DEFAULT_POSITION,
backgroundRepeat: 'no-repeat',
backgroundSize: HERO_DEFAULT_SIZE,
}
}
return { return {
backgroundColor: 'var(--bg-deep)', backgroundColor: 'var(--bg-deep)',
backgroundImage, backgroundImage: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || fallback}')`,
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`, backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
backgroundSize: bg.size || 'cover', backgroundSize: bg.size || 'cover',
@@ -44,7 +67,7 @@ export function parseLayout(str) {
// The current hardcoded hero as a HeroLayout, so the page is unchanged until // The current hardcoded hero as a HeroLayout, so the page is unchanged until
// staff publish their own. Font sizes use the existing clamp() strings so the // staff publish their own. Font sizes use the existing clamp() strings so the
// default stays responsive (editor-created text uses px). // default stays responsive (editor-created text uses px).
export function defaultLayout(teaser) { export function defaultLayout(teaser, name = 'Runic Gateway') {
return { return {
version: 1, version: 1,
background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' }, background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' },
@@ -62,7 +85,7 @@ export function defaultLayout(teaser) {
width: 760, width: 760,
lines: [ lines: [
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' }, { text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
{ text: 'UOMysticmoon', tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 }, { text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
{ text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 }, { text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 }, { text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
], ],

View File

@@ -10,55 +10,95 @@ function nameOf(who) {
const n = (v) => Number(v || 0).toLocaleString() const n = (v) => Number(v || 0).toLocaleString()
// A one-line human description of each event kind, keyed by kind. Each formatter
// takes the payload and returns a string. Conditional suffixes are pulled into
// locals so no template literal is nested inside another.
const DESCRIBERS = {
'vendor.sale': (p) => {
const qty = p.amount > 1 ? ` ×${p.amount}` : ''
return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp`
},
'player.death': (p) => {
const by = p.killer ? ` by ${nameOf(p.killer)}` : ''
return `${nameOf(p.who)} was slain${by}`
},
'player.murdered': (p) => {
const by = p.murderer ? ` by ${nameOf(p.murderer)}` : ''
return `${nameOf(p.victim)} was murdered${by}`
},
'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`,
'skill.gain': (p) => {
const base = p.base != null ? ` (${p.base})` : ''
return `${nameOf(p.who)} gained ${p.skill}${base}`
},
'fame.change': (p) => `${nameOf(p.who)}s fame changed to ${n(p.new)}`,
'karma.change': (p) => `${nameOf(p.who)}s karma changed to ${n(p.new)}`,
'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}`,
'house.decay': (p) => {
const region = p.region ? `${p.region}` : ''
return `${p.name || 'A house'} is now ${p.to || p.stage}${region}`
},
'mob.login': (p) => `${nameOf(p.who)} entered the world`,
'mob.logout': (p) => `${nameOf(p.who)} left the world`,
'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`,
'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`,
'server.shutdown': () => 'Shard shut down',
'server.crashed': (p) => {
const err = p.error ? `: ${p.error}` : ''
return `Shard crashed${err}`
},
'champ.update': (p) => {
const where = p.name || p.type || 'A champion spawn'
if (p.status === 'active' && p.bossUp) {
const boss = p.boss ? ` (${p.boss})` : ''
return `${where}: boss is up${boss}`
}
if (p.status === 'active') {
const level = p.level != null ? ` — level ${p.level}` : ''
return `${where} is active${level}`
}
if (p.status === 'cooldown') return `${where} is on cooldown`
return `${where} is ${p.status || 'idle'}`
},
'champ.remove': () => `A champion spawn ended`,
// Support (help-page) queue + in-game moderation (admin channel only)
'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`,
'page.updated': (p) => {
const claimed = p.handled ? ' (claimed)' : ''
return `Help page from ${nameOf(p.sender)} updated${claimed}`
},
'page.closed': (p) => `Help page ${p.pageId || ''} closed`,
'admin.audit': (p) => {
const on = p.target ? ` on ${p.target}` : ''
const origin = p.origin ? ` [${p.origin}]` : ''
return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}`
},
// Staff / sensitive (admin channel only)
'audit.set': (p) =>
`${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old}${p.new})`,
'audit.command': (p) => {
const args = p.args ? ` ${p.args}` : ''
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}`
},
'cheat.fastwalk': (p) => {
const ip = p.ip ? ` (${p.ip})` : ''
return `Fast-walk flagged: ${nameOf(p.who)}${ip}`
},
'account.login.attempt': (p) => {
const ip = p.ip ? ` from ${p.ip}` : ''
return `Login attempt: ${p.acct}${ip}`
},
'gold.change': (p) => {
const sign = p.delta >= 0 ? '+' : ''
return `${p.acct}: gold ${sign}${n(p.delta)}${n(p.new)}`
},
}
// A one-line human description of an event. Accepts either a stored event // A one-line human description of an event. Accepts either a stored event
// (with .payload) or a raw live frame (fields at top level). // (with .payload) or a raw live frame (fields at top level).
export function describe(ev) { export function describe(ev) {
const p = ev.payload || ev const fmt = DESCRIBERS[ev.kind]
switch (ev.kind) { return fmt ? fmt(ev.payload || ev) : ev.kind
case 'vendor.sale':
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp`
case 'player.death':
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
case 'player.murdered':
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
case 'mob.killed':
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
case 'skill.gain':
return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}`
case 'fame.change':
return `${nameOf(p.who)}s fame changed to ${n(p.new)}`
case 'karma.change':
return `${nameOf(p.who)}s karma changed to ${n(p.new)}`
case 'quest.complete':
return `${nameOf(p.who)} completed “${p.quest}`
case 'house.decay':
return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? `${p.region}` : ''}`
case 'mob.login':
return `${nameOf(p.who)} entered the world`
case 'mob.logout':
return `${nameOf(p.who)} left the world`
case 'economy.supply':
return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`
case 'server.hello':
return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`
case 'server.shutdown':
return 'Shard shut down'
case 'server.crashed':
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
// Staff / sensitive (admin channel only)
case 'audit.set':
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old}${p.new})`
case 'audit.command':
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}`
case 'cheat.fastwalk':
return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}`
case 'account.login.attempt':
return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}`
case 'gold.change':
return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)}${n(p.new)}`
default:
return ev.kind
}
} }
// Category grouping for the filter tabs. // Category grouping for the filter tabs.

View File

@@ -63,12 +63,16 @@ const NAV = [
title: 'Moderation', title: 'Moderation',
items: [ items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
], ],
}, },
{ {
title: 'System', title: 'System',
items: [ items: [
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] }, { to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] }, { to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
@@ -94,6 +98,9 @@ const TITLES = {
'/admin/wiki': 'Wiki Pages', '/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor', '/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation', '/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
'/admin/shard-ops': 'In-Game Ops',
'/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings', '/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log', '/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity', '/admin/bot-activity': 'Web Bot Activity',
@@ -102,9 +109,18 @@ const TITLES = {
'/admin/characters': 'My Characters', '/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication', '/admin/auth-providers': 'Authentication',
'/admin/users': 'Users', '/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security', '/admin/account': 'Account Security',
} }
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/characters')) return 'My Characters'
if (pathname.startsWith('/admin/users/')) return 'User'
return 'Admin'
}
const navBtnBase = { const navBtnBase = {
textAlign: 'left', textAlign: 'left',
borderRadius: 8, borderRadius: 8,
@@ -120,25 +136,21 @@ const navBtnBase = {
export default function AdminLayout() { export default function AdminLayout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { mode } = useSite() const { mode, siteTitle } = useSite()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = const title = TITLES[location.pathname] || sectionTitle(location.pathname)
TITLES[location.pathname] ||
(location.pathname.startsWith('/admin/moderation')
? 'Moderation'
: location.pathname.startsWith('/admin/characters')
? 'My Characters'
: 'Admin')
// The hero canvas editor needs room — let it use the full content width. // The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero' const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)' const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Moderators only get the moderation section + their own account security. // Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const isModerator = user?.role === 'moderator' const isModerator = user?.role === 'moderator'
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
const visible = (item) => { const visible = (item) => {
if (item.roles && !item.roles.includes(user?.role)) return false if (item.roles && !item.roles.includes(user?.role)) return false
if (isModerator) return item.to === '/admin/moderation' || item.to === '/admin/account' if (isModerator) return MOD_PATHS.includes(item.to)
return true return true
} }
// Drop items the current role can't see, then drop any now-empty group so an // Drop items the current role can't see, then drop any now-empty group so an
@@ -176,7 +188,9 @@ export default function AdminLayout() {
useEffect(() => { useEffect(() => {
if (!isModerator) return if (!isModerator) return
const p = location.pathname const p = location.pathname
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') { const allowed =
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
if (!allowed) {
navigate('/admin/moderation', { replace: true }) navigate('/admin/moderation', { replace: true })
} }
}, [isModerator, location.pathname, navigate]) }, [isModerator, location.pathname, navigate])
@@ -213,7 +227,7 @@ export default function AdminLayout() {
<MoonDot /> <MoonDot />
<div> <div>
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}> <div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
UOMysticmoon {siteTitle}
</div> </div>
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}> <div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
Admin Admin
@@ -222,7 +236,7 @@ export default function AdminLayout() {
</div> </div>
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}> <nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
{navGroups.map((group, gi) => { {navGroups.map((group) => {
const links = group.items.map((n) => ( const links = group.items.map((n) => (
<NavLink <NavLink
key={n.to} key={n.to}
@@ -244,7 +258,7 @@ export default function AdminLayout() {
// Untitled groups (Dashboard, Account) render their links directly. // Untitled groups (Dashboard, Account) render their links directly.
if (!group.title) { if (!group.title) {
return ( return (
<div key={`g${gi}`} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div key={group.items[0]?.to || 'group'} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{links} {links}
</div> </div>
) )

View File

@@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom' import { Link, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx' import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js' 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.
@@ -15,9 +17,6 @@ const SSO_ERRORS = {
error: 'Could not complete sign-in. Please try again.', error: 'Could not complete sign-in. Please try again.',
} }
const BG =
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots // Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
// skip) so real users never fill it but naive scripted bots do. Name must match // skip) so real users never fill it but naive scripted bots do. Name must match
// the server's HONEYPOT_FIELD ('company'). // the server's HONEYPOT_FIELD ('company').
@@ -33,6 +32,8 @@ const honeypotStyle = {
export default function AdminLogin() { export default function AdminLogin() {
const { user, login, loginTotp, ssoLoginTotp } = useAuth() const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const { siteTitle, heroImage } = useSite()
const BG = `linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('${heroImage}')`
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'
@@ -52,6 +53,9 @@ export default function AdminLogin() {
const [challenge, setChallenge] = useState('') const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false) const [ssoTotp, setSsoTotp] = useState(false)
const [trustDevice, setTrustDevice] = useState(false)
const [useRecovery, setUseRecovery] = useState(false)
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest } when the cap is hit
// SSO providers to offer (empty if none configured) + any error the callback // SSO providers to offer (empty if none configured) + any error the callback
// bounced us back with (?sso_error=...). // bounced us back with (?sso_error=...).
@@ -122,16 +126,23 @@ 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 {
const u = await loginTotp(challenge, code) const entered = code.trim()
navigate(destFor(u), { replace: true }) const data = await loginTotp(challenge, useRecovery ? '' : entered, {
recoveryCode: useRecovery ? entered : undefined,
trustDevice,
})
const to = destFor(data.user)
if (data.trustLimitReached) {
setTrustLimit({ devices: data.devices || [], dest: to })
setBusy(false)
return
}
navigate(to, { replace: true })
} }
} catch (err) { } catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message) const expired = err.status === 401 && /expired/i.test(err.message)
setError( const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
expired setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
? 'Your verification session expired. Please sign in again.'
: 'Invalid verification code.',
)
setBusy(false) setBusy(false)
if (expired) { if (expired) {
setStage('creds') setStage('creds')
@@ -140,6 +151,10 @@ export default function AdminLogin() {
} }
} }
let submitLabel = 'Sign in'
if (busy) submitLabel = 'Signing in…'
else if (stage === 'totp') submitLabel = 'Verify'
return ( return (
<main <main
style={{ style={{
@@ -151,7 +166,8 @@ export default function AdminLogin() {
backgroundColor: 'var(--bg-deep)', backgroundColor: 'var(--bg-deep)',
backgroundImage: BG, backgroundImage: BG,
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundSize: 'cover', backgroundRepeat: 'no-repeat',
backgroundSize: 'min(60vh, 520px)',
}} }}
> >
<div style={{ width: '100%', maxWidth: 400 }}> <div style={{ width: '100%', maxWidth: 400 }}>
@@ -160,7 +176,7 @@ export default function AdminLogin() {
<MoonDot size={15} glow={0.55} /> <MoonDot size={15} glow={0.55} />
</div> </div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}> <h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
UOMysticmoon {siteTitle}
</h1> </h1>
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}> <p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
Admin Panel Admin Panel
@@ -218,22 +234,40 @@ export default function AdminLogin() {
</div> </div>
</> </>
) : ( ) : (
<label style={{ display: 'block', marginBottom: 22 }}> <>
<span className="field-label">Authentication code</span> <label style={{ display: 'block', marginBottom: 14 }}>
<input <span className="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
type="text" <input
inputMode="numeric" type="text"
autoComplete="one-time-code" inputMode={useRecovery ? 'text' : 'numeric'}
autoFocus autoComplete="one-time-code"
placeholder="6-digit code" autoFocus
value={code} placeholder={useRecovery ? 'xxxxx-xxxxx' : '6-digit code'}
onChange={(e) => setCode(e.target.value)} value={code}
className="input" 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 className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
</span> {useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
</label> </span>
</label>
{!ssoTotp && (
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
Trust this device for 30 days (skip the code next time)
</label>
)}
{!ssoTotp && (
<button
type="button"
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
className="sans"
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
>
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
</button>
)}
</>
)} )}
{(error || (stage === 'creds' && ssoError)) && ( {(error || (stage === 'creds' && ssoError)) && (
@@ -248,7 +282,7 @@ export default function AdminLogin() {
className="btn btn-primary" className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }} style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
> >
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'} {submitLabel}
</button> </button>
{/* SSO providers — only on the credentials step, only if any are enabled. */} {/* SSO providers — only on the credentials step, only if any are enabled. */}
@@ -299,6 +333,14 @@ export default function AdminLogin() {
</Link> </Link>
</p> </p>
</div> </div>
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
onCancel={() => navigate(trustLimit.dest, { replace: true })}
/>
)}
</main> </main>
) )
} }

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx'
import ProviderIcon from '../../../components/ProviderIcon.jsx' import ProviderIcon from '../../../components/ProviderIcon.jsx'
import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through // Link/unlink external SSO identities to this account. Linking redirects through
@@ -123,10 +126,11 @@ export default function AccountAdmin() {
const [error, setError] = useState('') const [error, setError] = useState('')
// Enrollment state. // Enrollment state.
const [setup, setSetup] = useState(null) // { qr, otpauthUrl } const [setup, setSetup] = useState(null) // fields qr and otpauthUrl once enrolling
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
async function load() { async function load() {
try { try {
@@ -164,9 +168,10 @@ export default function AccountAdmin() {
setMsg('') setMsg('')
setError('') setError('')
try { try {
await api.admin.totpEnable(code.trim()) const res = await api.admin.totpEnable(code.trim())
setSetup(null) setSetup(null)
setCode('') setCode('')
setNewCodes(res?.recoveryCodes || null)
setMsg('Two-factor authentication is now enabled.') setMsg('Two-factor authentication is now enabled.')
await load() await load()
} catch (err) { } catch (err) {
@@ -302,6 +307,21 @@ export default function AccountAdmin() {
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>} {msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>} {error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
{/* One-time recovery codes shown right after enabling 2FA. */}
{newCodes && (
<div style={{ marginTop: 20 }}>
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
</div>
)}
{/* Trusted devices + recovery-code management, only relevant with 2FA on. */}
{enabled && (
<>
<TrustedDevicesPanel />
<RecoveryCodesPanel hasPassword={account?.has_password !== false} />
</>
)}
<LinkedAccounts /> <LinkedAccounts />
</section> </section>
) )

View File

@@ -23,7 +23,7 @@ export default function AdminCharacter() {
{restarting && <ErrorState message="The game server is restarting — try again shortly." />} {restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />} {forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />} {error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />} {!loading && !error && data && <CharacterSheet char={data} moderation />}
</div> </div>
) )
} }

View File

@@ -0,0 +1,288 @@
import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import Modal from '../../../components/Modal.jsx'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Staff queue for moderation appeals (bans/mutes appealed by players). Mirrors
// the Moderation.jsx tile/feed layout: a status-filter segmented control over a
// flat table, with per-row Claim / Resolve actions. Resolve opens a modal — no
// browser confirm()/alert() anywhere here.
const STATUS_TABS = [
{ key: 'open', label: 'Open', param: undefined },
{ key: 'pending', label: 'Pending', param: 'pending' },
{ key: 'under_review', label: 'Under review', param: 'under_review' },
{ key: 'approved', label: 'Approved', param: 'approved' },
{ key: 'denied', label: 'Denied', param: 'denied' },
{ key: 'withdrawn', label: 'Withdrawn', param: 'withdrawn' },
{ key: 'all', label: 'All', param: 'all' },
]
const STATUS_STYLE = {
pending: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
under_review: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
approved: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
denied: { color: '#d98b84', background: 'rgba(217,139,132,0.16)', border: '1px solid rgba(217,139,132,0.4)' },
withdrawn: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
}
const STATUS_LABEL = {
pending: 'Pending',
under_review: 'Under review',
approved: 'Approved',
denied: 'Denied',
withdrawn: 'Withdrawn',
}
function excerpt(text, n = 90) {
if (!text) return ''
return text.length > n ? `${text.slice(0, n)}` : text
}
export default function Appeals() {
const navigate = useNavigate()
const [tab, setTab] = useState('open')
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const [busyId, setBusyId] = useState('')
const [resolving, setResolving] = useState(null) // the appeal being resolved
const [notice, setNotice] = useState(null) // fields text and tone
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
const { loading, error, data } = useAsync(
() => api.admin.getAppeals({ status: activeTab.param, limit: 100 }),
[tab, tick],
)
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
async function claim(appeal) {
setBusyId(appeal.id)
setNotice(null)
try {
await api.admin.claimAppeal(appeal.id)
reload()
} catch (err) {
setNotice({ text: err.message || 'Could not claim this appeal.', tone: 'error' })
} finally {
setBusyId('')
}
}
function onResolved(appeal, result) {
setResolving(null)
const { reversal } = result
if (reversal?.attempted && reversal.ok) {
setNotice({ text: `Discord ${appeal.action_type} lifted.`, tone: 'ok' })
} else if (reversal?.attempted && !reversal.ok) {
setNotice({ text: 'Reversal failed — reverse manually in Discord.', tone: 'error' })
} else {
setNotice(null)
}
reload()
}
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load appeals." />
const rows = data || []
return (
<section>
{/* Status filter */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
{STATUS_TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="pill"
style={tab === t.key ? activePill : undefined}
>
{t.label}
</button>
))}
</div>
{notice && (
<p
className="sans"
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
>
{notice.text}
</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Target</th>
<th className="adm-th">Action</th>
<th className="adm-th">Appeal</th>
<th className="adm-th">Submitted by</th>
<th className="adm-th">Age</th>
<th className="adm-th">Status</th>
<th className="adm-th">Reversal</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={8} style={muted}>
No appeals match this filter.
</td>
</tr>
)}
{rows.map((a) => (
<tr key={a.id}>
<td className="adm-td">
<span className="link-accent" onClick={() => goUser(a.discord_user_id)}>
{a.action_target_tag || a.discord_user_id}
</span>
</td>
<td className="adm-td">
<span className={`badge badge-${a.action_type}`}>{a.action_type}</span>
</td>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 320 }}>
{excerpt(a.submitted_text)}
</td>
<td className="adm-td dim">{a.submitter_username || '—'}</td>
<td className="adm-td dim" title={dateTime(a.submitted_at)}>{ago(a.submitted_at)}</td>
<td className="adm-td">
<span className="badge" style={STATUS_STYLE[a.status]}>{STATUS_LABEL[a.status] || a.status}</span>
</td>
<td className="adm-td dim">
{a.reversal_status === 'done' && <span style={{ color: '#7fd0a4' }}>Lifted</span>}
{a.reversal_status === 'failed' && <span style={{ color: '#d98b84' }}>Failed</span>}
{(!a.reversal_status || a.reversal_status === 'none') && '—'}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{a.status === 'pending' && (
<button
onClick={() => claim(a)}
disabled={busyId === a.id}
className="pill"
style={{ marginRight: 6 }}
>
{busyId === a.id ? 'Claiming…' : 'Claim'}
</button>
)}
{(a.status === 'pending' || a.status === 'under_review') && (
<button onClick={() => setResolving(a)} className="btn btn-primary btn-sq" style={{ padding: '5px 12px', fontSize: '0.82rem' }}>
Resolve
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{resolving && (
<ResolveModal appeal={resolving} onClose={() => setResolving(null)} onResolved={onResolved} />
)}
</section>
)
}
function ResolveModal({ appeal, onClose, onResolved }) {
const [status, setStatus] = useState('approved')
const [staffResponse, setStaffResponse] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function submit() {
setBusy(true)
setError('')
try {
const result = await api.admin.resolveAppeal(appeal.id, {
status,
staff_response: staffResponse.trim() || undefined,
})
onResolved(appeal, result)
} catch (err) {
setError(err.message || 'Could not resolve this appeal.')
setBusy(false)
}
}
const verb = status === 'approved' ? 'approved' : 'denied'
return (
<Modal
title={`Resolve appeal — ${appeal.action_target_tag || appeal.discord_user_id}`}
onClose={onClose}
width={560}
footer={
<>
<button onClick={onClose} disabled={busy} className="pill">
Cancel
</button>
<button onClick={submit} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : `Mark ${verb}`}
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
<div>
<span className="field-label">Submitted appeal</span>
<div
className="sans"
style={{
marginTop: 6,
padding: '10px 12px',
border: '1px solid var(--line)',
borderRadius: 8,
color: 'var(--text)',
fontSize: '0.86rem',
whiteSpace: 'pre-wrap',
maxHeight: 200,
overflow: 'auto',
}}
>
{appeal.submitted_text}
</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={() => setStatus('approved')}
className="pill"
style={status === 'approved' ? { background: 'var(--blue)', color: 'var(--ink)', borderColor: '#7fd0a4' } : undefined}
>
Approve
</button>
<button
onClick={() => setStatus('denied')}
className="pill"
style={status === 'denied' ? { background: 'var(--blue)', color: 'var(--ink)', borderColor: '#d98b84' } : undefined}
>
Deny
</button>
</div>
<label>
<span className="field-label">Staff response (optional)</span>
<textarea
className="textarea"
placeholder="Message shown to the player…"
value={staffResponse}
onChange={(e) => setStaffResponse(e.target.value)}
rows={4}
style={{ width: '100%' }}
/>
</label>
</div>
</Modal>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const muted = { color: 'var(--muted)' }

View File

@@ -44,6 +44,14 @@ function CallbackHint({ id }) {
) )
} }
// Live = enabled and healthy; Incomplete = enabled but missing/invalid config;
// Disabled otherwise.
function ProviderStatus({ provider: p }) {
if (p.enabled && p.health.valid) return <span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
if (p.enabled) return <span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
return <span className="sans dim">Disabled</span>
}
function Toggle({ checked, onChange, label }) { function Toggle({ checked, onChange, label }) {
return ( return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}> <label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
@@ -292,13 +300,7 @@ function CustomProviders({ items, onChanged }) {
<td className="adm-td" style={{ color: 'var(--head)' }}>{p.name}</td> <td className="adm-td" style={{ color: 'var(--head)' }}>{p.name}</td>
<td className="adm-td dim">{p.kind}</td> <td className="adm-td dim">{p.kind}</td>
<td className="adm-td"> <td className="adm-td">
{p.enabled && p.health.valid ? ( <ProviderStatus provider={p} />
<span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
) : p.enabled ? (
<span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
) : (
<span className="sans dim">Disabled</span>
)}
</td> </td>
<td className="adm-td" style={{ textAlign: 'right' }}> <td className="adm-td" style={{ textAlign: 'right' }}>
<span className="link-accent" onClick={() => setEditing(p)}>Edit</span> <span className="link-accent" onClick={() => setEditing(p)}>Edit</span>

View File

@@ -115,8 +115,8 @@ export default function BotActivityAdmin() {
</td> </td>
</tr> </tr>
)} )}
{events.map((ev, i) => ( {events.map((ev) => (
<tr key={`${ev.ts}-${ev.ip}-${i}`}> <tr key={`${ev.ts}-${ev.ip}-${ev.type}`}>
<td className="adm-td dim">{dateTime(ev.ts)}</td> <td className="adm-td dim">{dateTime(ev.ts)}</td>
<td className="adm-td" style={{ ...mono, color: 'var(--text)' }}> <td className="adm-td" style={{ ...mono, color: 'var(--text)' }}>
{ev.ip} {ev.ip}

View File

@@ -45,6 +45,9 @@ export default function Dashboard() {
const changed = dash.last_change || {} const changed = dash.last_change || {}
let modeLabel = isLive ? 'Switch to Maintenance' : 'Switch to Live'
if (busy) modeLabel = 'Saving…'
return ( return (
<section> <section>
<div <div
@@ -82,7 +85,7 @@ export default function Dashboard() {
className="sans" className="sans"
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }} style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
> >
{busy ? 'Saving…' : isLive ? 'Switch to Maintenance' : 'Switch to Live'} {modeLabel}
</button> </button>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings // 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 // page. Sending is authorized by an in-app "Connect Gmail" consent flow that
@@ -52,6 +53,7 @@ function StatusPanel({ config }) {
} }
export default function EmailDelivery() { export default function EmailDelivery() {
const { siteTitle } = useSite()
const [config, setConfig] = useState(null) const [config, setConfig] = useState(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [senderName, setSenderName] = useState('') const [senderName, setSenderName] = useState('')
@@ -59,7 +61,7 @@ export default function EmailDelivery() {
const [busy, setBusy] = useState('') const [busy, setBusy] = useState('')
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
const [actionError, setActionError] = useState('') const [actionError, setActionError] = useState('')
const [banner, setBanner] = useState(null) // { kind: 'ok'|'err', text } const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
const load = useCallback(async (seedForm = false) => { const load = useCallback(async (seedForm = false) => {
try { try {
@@ -213,7 +215,7 @@ export default function EmailDelivery() {
onChange={(e) => setSenderName(e.target.value)} onChange={(e) => setSenderName(e.target.value)}
className="input" className="input"
autoComplete="off" autoComplete="off"
placeholder="UOMysticmoon" placeholder={siteTitle}
/> />
</label> </label>

View File

@@ -23,6 +23,34 @@ function tooLargeToUpload(size) {
) )
} }
// Label for an image-upload button: busy, replace-existing, or first upload.
function uploadLabel(up, hasSrc) {
if (up) return 'Uploading…'
return hasSrc ? 'Replace' : 'Upload'
}
// Shared image-upload behaviour for the element panels that point props.src at
// the uploaded URL (moon + image). Returns the busy flag and file <input> handler.
function useImageUpload(onProps) {
const [up, setUp] = useState(false)
async function onFile(e) {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
if (tooLargeToUpload(f.size)) return
setUp(true)
try {
const { url } = await api.admin.upload(f)
onProps({ src: url })
} catch {
/* ignore */
} finally {
setUp(false)
}
}
return { up, onFile }
}
function newElement(type, z) { function newElement(type, z) {
const base = { id: genId(), type, x: 50, y: 50, z, anchor: 'center' } const base = { id: genId(), type, x: 50, y: 50, z, anchor: 'center' }
if (type === 'text_block') { if (type === 'text_block') {
@@ -53,6 +81,23 @@ function scaleFontSize(v, ratio) {
return v return v
} }
// The props patch for a resize drag, per element type: image width is a % of the
// canvas, moon size is px, and a text_block resizes its box and scales every
// line's font proportionally. `ctx` carries the drag origin + measured geometry.
function resizePatch(el, ctx) {
const { orig, dxPx, dxLogical, rectWidth, baseWidth, baseLines } = ctx
if (el.type === 'image') {
return { width: Math.round(clamp(orig + (dxPx / rectWidth) * 100, 5, 100)) } // %
}
if (el.type === 'moon') {
return { size: Math.round(clamp(orig + dxLogical, 24, 400)) } // px
}
const width = Math.round(clamp(orig + dxLogical, 120, 1180))
const ratio = baseWidth ? width / baseWidth : 1
const lines = baseLines.map((l) => ({ ...l, fontSize: scaleFontSize(l.fontSize, ratio) }))
return { width, lines }
}
export default function HeroEditor() { export default function HeroEditor() {
const [layout, setLayout] = useState(null) const [layout, setLayout] = useState(null)
const [live, setLive] = useState(null) const [live, setLive] = useState(null)
@@ -203,7 +248,9 @@ export default function HeroEditor() {
if (!dim) return if (!dim) return
const rect = canvasRef.current.getBoundingClientRect() const rect = canvasRef.current.getBoundingClientRect()
const sx = e.clientX const sx = e.clientX
const orig = el.props?.[dim] ?? (dim === 'width' && el.type === 'image' ? 40 : dim === 'width' ? 600 : 64) let defaultDim = 64
if (dim === 'width') defaultDim = el.type === 'image' ? 40 : 600
const orig = el.props?.[dim] ?? defaultDim
// Snapshot the starting width + lines for text blocks so font scaling is always // Snapshot the starting width + lines for text blocks so font scaling is always
// computed against the drag origin (no rounding drift as the pointer moves). // computed against the drag origin (no rounding drift as the pointer moves).
const baseWidth = el.type === 'text_block' ? orig : 0 const baseWidth = el.type === 'text_block' ? orig : 0
@@ -217,17 +264,7 @@ export default function HeroEditor() {
const move = (ev) => { const move = (ev) => {
const dxPx = ev.clientX - sx const dxPx = ev.clientX - sx
const dxLogical = dxPx / scale // client px → stage px const dxLogical = dxPx / scale // client px → stage px
if (el.type === 'image') { updateProps(el.id, resizePatch(el, { orig, dxPx, dxLogical, rectWidth: rect.width, baseWidth, baseLines }))
updateProps(el.id, { width: Math.round(clamp(orig + (dxPx / rect.width) * 100, 5, 100)) }) // %
} else if (el.type === 'moon') {
updateProps(el.id, { size: Math.round(clamp(orig + dxLogical, 24, 400)) }) // px
} else {
// text_block: resize the box and scale every line's font proportionally.
const width = Math.round(clamp(orig + dxLogical, 120, 1180))
const ratio = baseWidth ? width / baseWidth : 1
const lines = baseLines.map((l) => ({ ...l, fontSize: scaleFontSize(l.fontSize, ratio) }))
updateProps(el.id, { width, lines })
}
} }
const up = () => { const up = () => {
node.removeEventListener('pointermove', move) node.removeEventListener('pointermove', move)
@@ -534,24 +571,7 @@ const swatch = { width: '100%', height: 38, padding: 2, border: '1px solid var(-
function MoonPanel({ element, onProps }) { function MoonPanel({ element, onProps }) {
const p = element.props || {} const p = element.props || {}
const [up, setUp] = useState(false) const { up, onFile } = useImageUpload(onProps)
// Reuses the shared admin upload endpoint (same as the image/background panels);
// a successful upload just points props.src at the returned URL.
async function onFile(e) {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
if (tooLargeToUpload(f.size)) return
setUp(true)
try {
const { url } = await api.admin.upload(f)
onProps({ src: url })
} catch {
/* ignore */
} finally {
setUp(false)
}
}
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div> <div>
@@ -562,7 +582,7 @@ function MoonPanel({ element, onProps }) {
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>Using the default moon from the hero artwork.</p> <p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>Using the default moon from the hero artwork.</p>
)} )}
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}> <label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'} {uploadLabel(up, !!p.src)}
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} /> <input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
</label> </label>
{p.src && ( {p.src && (
@@ -613,29 +633,14 @@ function BadgePanel({ element, onProps }) {
function ImagePanel({ element, onProps }) { function ImagePanel({ element, onProps }) {
const p = element.props || {} const p = element.props || {}
const [up, setUp] = useState(false) const { up, onFile } = useImageUpload(onProps)
async function onFile(e) {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
if (tooLargeToUpload(f.size)) return
setUp(true)
try {
const { url } = await api.admin.upload(f)
onProps({ src: url })
} catch {
/* ignore */
} finally {
setUp(false)
}
}
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div> <div>
<span className="field-label">Image</span> <span className="field-label">Image</span>
{p.src && <img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />} {p.src && <img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />}
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}> <label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'} {uploadLabel(up, !!p.src)}
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} /> <input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
</label> </label>
</div> </div>

View File

@@ -0,0 +1,119 @@
import { useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { api } from '../../../api/client.js'
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
// live from the admin SSE channel (house.update / house.remove).
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
function DecayBadge({ decay, isIdoc }) {
const label = isIdoc ? 'IDOC' : decay
if (!label) return null
const tone = DECAY_TONE[label] || 'var(--muted)'
return (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
{label}
</span>
)
}
function ownerLabel(h) {
return h.ownerName || h.ownerAcct || null
}
function HouseRow({ h }) {
const owner = ownerLabel(h)
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.name || 'An unnamed house'}
</strong>
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
</div>
</div>
{h.price != null && (
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
</div>
)}
</div>
)
}
export default function HousesAdmin() {
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
// Full registry deltas ride the admin SSE channel (never the public one).
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
const [q, setQ] = useState('')
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev.serial) continue
if (ev.kind === 'house.update') {
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
} else if (ev.kind === 'house.remove') {
map.delete(ev.serial)
} else if (ev.kind === 'house.decay') {
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
}
}
return [...map.values()]
}, [data, events])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the house registry." />
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
{board.length.toLocaleString()} houses
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
</p>
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
</div>
{board.length === 0 ? (
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
)}
{board.length > 0 && filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match {q}.</p>
)}
</section>
)
}

View File

@@ -0,0 +1,186 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Admin email invites: send an invite at a chosen access level, see recent
// invites and their status, revoke pending ones. When email delivery isn't
// configured the create response hands back the accept link to copy manually.
const ROLES = ['player', 'moderator', 'editor', 'admin']
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator', player: 'badge-player' }
const STATUS_COLOR = { pending: 'var(--accent)', accepted: '#7fd0a4', revoked: 'var(--muted)' }
function CopyLink({ url }) {
const [copied, setCopied] = useState(false)
async function copy() {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 1800)
} catch {
/* clipboard blocked — the link is selectable in the box regardless */
}
}
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
<code
onClick={(e) => { const r = document.createRange(); r.selectNodeContents(e.currentTarget); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r) }}
style={{ flex: 1, wordBreak: 'break-all', color: 'var(--head)', background: 'var(--panel-flat)', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--line)', cursor: 'text', fontSize: '0.8rem' }}
>
{url}
</code>
<button type="button" onClick={copy} className="btn btn-sq" style={{ flex: 'none' }}>
{copied ? 'Copied ✓' : 'Copy'}
</button>
</div>
)
}
function CreateInvite({ onCreated }) {
const [email, setEmail] = useState('')
const [role, setRole] = useState('player')
const [sendEmail, setSendEmail] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState(null) // fields emailed, acceptUrl, emailError
async function submit(e) {
e.preventDefault()
setError(''); setResult(null)
if (!email.trim()) return setError('Enter an email address.')
setBusy(true)
try {
const res = await api.admin.createInvite(email.trim(), role, sendEmail)
setResult(res)
setEmail('')
await onCreated()
} catch (err) {
setError(err.message || 'Could not create the invite.')
} finally {
setBusy(false)
}
}
const submitLabel = sendEmail ? 'Create & email' : 'Create link'
let resultText
if (result?.emailed) {
resultText = 'Invitation emailed. You can also share this single-use link:'
} else {
const emailNote = result?.emailError ? ` (email not sent: ${result.emailError})` : ''
resultText = `Invite created${emailNote}. Share this single-use link:`
}
return (
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
<form onSubmit={submit} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Email</span>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="person@example.com" />
</label>
<label>
<span className="field-label">Access level</span>
<select value={role} onChange={(e) => setRole(e.target.value)} className="select">
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : submitLabel}
</button>
</form>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: '0.85rem', color: 'var(--ink)', cursor: 'pointer' }}>
<input type="checkbox" checked={sendEmail} onChange={(e) => setSendEmail(e.target.checked)} />
Email the invitation (otherwise just generate a link to share)
</label>
{error && <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{result && (
<div style={{ marginTop: 14 }}>
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
{resultText}
</p>
<CopyLink url={result.acceptUrl} />
</div>
)}
</div>
)
}
export default function InvitesAdmin() {
const [invites, setInvites] = useState(null)
const [error, setError] = useState('')
const load = useCallback(async () => {
setError('')
try {
setInvites(await api.admin.listInvites())
} catch {
setError('Could not load invites.')
}
}, [])
useEffect(() => { load() }, [load])
async function revoke(id) {
if (!window.confirm('Revoke this pending invitation?')) return
try {
await api.admin.revokeInvite(id)
await load()
} catch {
/* surfaced by the row staying; keep it simple */
}
}
if (error) return <ErrorState message={error} />
return (
<section>
<CreateInvite onCreated={load} />
{!invites ? (
<Loading />
) : (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Email</th>
<th className="adm-th">Role</th>
<th className="adm-th">Status</th>
<th className="adm-th">Expires</th>
<th className="adm-th">Created</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{invites.length === 0 && (
<tr><td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No invites yet.</td></tr>
)}
{invites.map((iv) => {
const status = iv.status === 'pending' && iv.expired ? 'expired' : iv.status
return (
<tr key={iv.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>{iv.email}</td>
<td className="adm-td"><span className={`badge ${ROLE_BADGE[iv.role] || 'badge-editor'}`}>{iv.role}</span></td>
<td className="adm-td" style={{ color: STATUS_COLOR[iv.status] || 'var(--muted)', textTransform: 'capitalize' }}>{status}</td>
<td className="adm-td dim">{dateTime(iv.expiresAt)}</td>
<td className="adm-td dim">{dateTime(iv.createdAt)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
{iv.status === 'pending' && (
<button type="button" className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }} onClick={() => revoke(iv.id)}>
Revoke
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
)
}

View File

@@ -35,6 +35,7 @@ export default function ModerationUser() {
api.admin.modUser(discordId), api.admin.modUser(discordId),
api.admin.modUserActions(discordId, { limit: 200 }), api.admin.modUserActions(discordId, { limit: 200 }),
api.admin.modUserNotes(discordId), api.admin.modUserNotes(discordId),
api.admin.getUserAppeals(discordId),
]), ]),
[discordId, tick], [discordId, tick],
) )
@@ -42,10 +43,19 @@ export default function ModerationUser() {
if (loading) return <Loading /> if (loading) return <Loading />
if (error) return <ErrorState message="Could not load this users history." /> if (error) return <ErrorState message="Could not load this users history." />
const [summary, actions, notes] = data const [summary, actions, notes, appeals] = data
const counts = summary.counts || {} const counts = summary.counts || {}
const tabActions = actions.filter((a) => a.action_type === tab) const tabActions = actions.filter((a) => a.action_type === tab)
let tabBody
if (tab === 'notes') {
tabBody = <NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
} else if (tab === 'appeals') {
tabBody = <AppealsTab rows={appeals} />
} else {
tabBody = <ActionTable rows={tabActions} showDuration={tab === 'mute'} />
}
return ( return (
<section> <section>
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}> <Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
@@ -83,13 +93,12 @@ export default function ModerationUser() {
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}> <TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
Notes ({summary.notes_count || 0}) Notes ({summary.notes_count || 0})
</TabButton> </TabButton>
<TabButton active={tab === 'appeals'} onClick={() => setTab('appeals')}>
Appeals ({appeals.length})
</TabButton>
</div> </div>
{tab === 'notes' ? ( {tabBody}
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
) : (
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
)}
</section> </section>
) )
} }
@@ -164,6 +173,63 @@ function ActionTable({ rows, showDuration }) {
) )
} }
const APPEAL_STATUS_STYLE = {
pending: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
under_review: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
approved: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
denied: { color: '#d98b84', background: 'rgba(217,139,132,0.16)', border: '1px solid rgba(217,139,132,0.4)' },
withdrawn: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
}
const APPEAL_STATUS_LABEL = {
pending: 'Pending',
under_review: 'Under review',
approved: 'Approved',
denied: 'Denied',
withdrawn: 'Withdrawn',
}
function AppealsTab({ rows }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Action</th>
<th className="adm-th">Appeal</th>
<th className="adm-th">Staff response</th>
<th className="adm-th">Status</th>
<th className="adm-th">Reversal</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No appeals from this user.</td>
</tr>
)}
{rows.map((a) => (
<tr key={a.id}>
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 260, whiteSpace: 'pre-wrap' }}>{a.submitted_text}</td>
<td className="adm-td dim" style={{ maxWidth: 220, whiteSpace: 'pre-wrap' }}>{a.staff_response || '—'}</td>
<td className="adm-td">
<span className="badge" style={APPEAL_STATUS_STYLE[a.status]}>{APPEAL_STATUS_LABEL[a.status] || a.status}</span>
</td>
<td className="adm-td dim">
{a.reversal_status === 'done' && <span style={{ color: '#7fd0a4' }}>Lifted</span>}
{a.reversal_status === 'failed' && <span style={{ color: '#d98b84' }}>Failed</span>}
{(!a.reversal_status || a.reversal_status === 'none') && '—'}
</td>
<td className="adm-td dim" title={dateTime(a.submitted_at)}>{ago(a.submitted_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function NotesTab({ discordId, notes, isAdmin, onAdded }) { function NotesTab({ discordId, notes, isAdmin, onAdded }) {
const [body, setBody] = useState('') const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('staff_only') const [visibility, setVisibility] = useState('staff_only')

View File

@@ -252,6 +252,9 @@ export default function PageBuilder() {
const published = form.status === 'published' const published = form.status === 'published'
let saveLabel = isEdit ? 'Save' : 'Create'
if (busy) saveLabel = 'Saving…'
return ( return (
<section> <section>
{/* Toolbar */} {/* Toolbar */}
@@ -268,7 +271,7 @@ export default function PageBuilder() {
</button> </button>
)} )}
<button className="btn btn-primary btn-sq" onClick={() => save()} disabled={busy}> <button className="btn btn-primary btn-sq" onClick={() => save()} disabled={busy}>
{busy ? 'Saving…' : isEdit ? 'Save' : 'Create'} {saveLabel}
</button> </button>
</div> </div>
@@ -277,7 +280,7 @@ export default function PageBuilder() {
{error} {error}
{details.length > 0 && ( {details.length > 0 && (
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}> <ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{details.map((d, i) => <li key={i}>{d}</li>)} {details.map((d) => <li key={d}>{d}</li>)}
</ul> </ul>
)} )}
</div> </div>
@@ -386,7 +389,7 @@ export default function PageBuilder() {
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Show in navigation</span> <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Show in navigation</span>
</label> </label>
<SelectField label="Nav group" value={form.settings.navGroup} onChange={setSetting('navGroup')} options={NAV_GROUPS} /> <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." /> <TextField label="Nav order" value={form.settings.navOrder ?? ''} onChange={(v) => setSetting('navOrder')(v === '' ? null : v.replace(/\D/g, ''))} hint="Lower numbers appear first." />
</div> </div>
</div> </div>

View File

@@ -35,6 +35,18 @@ const FIELDS = [
], ],
fallback: 'disabled', fallback: 'disabled',
}, },
{
key: 'game_account_signup',
label: 'Game-account creation',
help: 'Whether players can create a GAME account (for the game client) from the site. The game servers own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
options: [
{ value: 'disabled', label: 'Disabled — link an existing account only' },
{ value: 'website', label: 'Website — the site creates game accounts' },
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
{ value: 'game', label: 'Game only — created in the game client, not the site' },
],
fallback: 'disabled',
},
] ]
export default function SettingsAdmin() { export default function SettingsAdmin() {
@@ -97,14 +109,15 @@ export default function SettingsAdmin() {
// A rich field can't live inside a <label> (nested toolbar buttons + // A rich field can't live inside a <label> (nested toolbar buttons +
// contenteditable), so it uses a plain <div> wrapper instead. // contenteditable), so it uses a plain <div> wrapper instead.
const Wrap = f.rich ? 'div' : 'label' const Wrap = f.rich ? 'div' : 'label'
return ( let field
<Wrap key={f.key} style={{ display: 'block' }}> if (f.rich) {
<span className="field-label">{f.label}</span> field = (
{f.rich ? (
<Suspense fallback={<span className="spin" />}> <Suspense fallback={<span className="spin" />}>
<RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" /> <RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" />
</Suspense> </Suspense>
) : f.options ? ( )
} else if (f.options) {
field = (
<select value={values[f.key]} onChange={set(f.key)} className="select"> <select value={values[f.key]} onChange={set(f.key)} className="select">
{f.options.map((o) => ( {f.options.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
@@ -112,11 +125,16 @@ export default function SettingsAdmin() {
</option> </option>
))} ))}
</select> </select>
) : f.long ? ( )
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} /> } else if (f.long) {
) : ( field = <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" /> } else {
)} field = <input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
}
return (
<Wrap key={f.key} style={{ display: 'block' }}>
<span className="field-label">{f.label}</span>
{field}
{f.help && ( {f.help && (
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}> <span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
{f.help} {f.help}

View File

@@ -0,0 +1,291 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { describe } from '../../../lib/shardEvents.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
// unban) and the help-page support queue, plus a live audit log. Open to admins
// and moderators. The acting staff member (`actor`) is attached server-side from
// the session — nothing here sends it — so every action is attributable.
function Flash({ ok, err }) {
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
return null
}
// ── Broadcast ────────────────────────────────────────────────────────────────
function Broadcast() {
const [text, setText] = useState('')
const [hue, setHue] = useState('')
const [busy, setBusy] = useState(false)
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
async function send() {
if (!text.trim()) return setErr('Enter a message.')
setBusy(true); setOk(''); setErr('')
try {
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
setOk('Broadcast sent.')
setText('')
} catch (e) {
setErr(e.message || 'Could not broadcast.')
} finally {
setBusy(false)
}
}
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
A system message shown to everyone online right now.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message</span>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Hue (optional)</span>
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
function AccountActions() {
const [account, setAccount] = useState('')
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const acct = account.trim()
function guard() {
if (!acct) {
setErr('Enter an account name.')
return false
}
return true
}
async function run(label, fn, done) {
if (!guard()) return
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
const n = r?.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked ${acct}${sessions}.`
})
const ban = () =>
run(
'ban',
() =>
api.admin.shardOps.ban({
account: acct,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned ${acct}${when}.`
},
)
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Account</span>
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'block', maxWidth: 200 }}>
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
<span className="field-label">Ban reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Support (help-page) queue ────────────────────────────────────────────────
function PageRow({ page, onDone }) {
const [message, setMessage] = useState('')
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
async function respond(close) {
if (!message.trim()) return setErr('Enter a reply first.')
setBusy(close ? 'respond-close' : 'respond'); setErr('')
try {
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
onDone()
} catch (e) {
setErr(e.message || 'Could not send.')
setBusy('')
}
}
async function close() {
setBusy('close'); setErr('')
try {
await api.admin.shardOps.closePage(page.pageId)
onDone()
} catch (e) {
setErr(e.message || 'Could not close.')
setBusy('')
}
}
return (
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{page.sender?.name || page.pageId}
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
</div>
</div>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
</div>
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
</div>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
</div>
)
}
function SupportQueue() {
const [pages, setPages] = useState(null)
const [err, setErr] = useState('')
const pollRef = useRef(null)
const load = useCallback(async () => {
try {
setPages(await api.admin.shardOps.pages())
} catch {
setErr('Could not load the support queue.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 7000)
return () => clearInterval(pollRef.current)
}, [load])
let queueBody
if (pages == null) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
} else if (pages.length === 0) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
} else {
queueBody = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
</div>
)
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Open help pages from players. A reply reaches them in game (or on their next login).
</p>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
{queueBody}
</section>
)
}
// ── Audit log ────────────────────────────────────────────────────────────────
// Seeded from the stored admin.audit history, then kept live from the admin SSE
// channel (which carries every kind — we filter to admin.audit here).
function AuditLog() {
const [seed, setSeed] = useState([])
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
useEffect(() => {
api.admin.shardOps
.audit(50)
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
.catch(() => setSeed([]))
}, [])
// Live events on top; fall back to the seed for anything older than the live tail.
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
{rows.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
export default function ShardOps() {
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
<Broadcast />
<AccountActions />
<SupportQueue />
<AuditLog />
</section>
)
}

View File

@@ -0,0 +1,302 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime, ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
// Admin read-only view of one user's shard (uo-link) footprint: linked game
// accounts + character rosters, currently-online characters, houses (incl.
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
// Reached from the Users table's "View" action; Edit stays a separate modal.
const ROLE_BADGE = {
admin: 'badge-admin',
editor: 'badge-editor',
moderator: 'badge-moderator',
player: 'badge-player',
}
function SectionTitle({ children }) {
return (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
// Currently-online characters on the user's accounts, with where they are. The
// per-character Online/Offline badge lives in the roster; this adds location.
function OnlineNow({ scope }) {
const { data } = useAsync(() => scope.online(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this users accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
// Admin security controls for one user: their trusted devices (view + revoke) and
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
try {
setDevices(await api.admin.userTrustedDevices(userId))
} catch {
setError('Could not load trusted devices.')
}
}, [userId])
useEffect(() => {
load()
}, [load])
async function revoke(deviceId) {
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeUserTrustedDevice(userId, deviceId)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Revoke ALL of this users trusted devices?')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeAllUserTrustedDevices(userId)
setMsg('All trusted devices revoked.')
await load()
} catch {
setError('Could not revoke devices.')
} finally {
setBusy(false)
}
}
async function resetMfa() {
if (!window.confirm('Reset this users two-factor? This turns TOTP off, revokes their trusted devices, and clears their recovery codes so they can sign in with their password.')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.resetUserMfa(userId)
setMsg('Two-factor has been reset for this user.')
await load()
} catch {
setError('Could not reset two-factor.')
} finally {
setBusy(false)
}
}
const fmt = (d) => {
const t = d ? new Date(d) : null
return t && !Number.isNaN(t.getTime()) ? t.toLocaleDateString() : '—'
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Security &amp; two-factor</SectionTitle>
{devices == null ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
) : devices.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No trusted devices.</p>
) : (
<ul style={{ listStyle: 'none', margin: '0 0 14px', padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{devices.map((d) => (
<li key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'} · last used {fmt(d.lastUsedAt)} · expires {fmt(d.expiresAt)}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</li>
))}
</ul>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{devices && devices.length > 0 && (
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke all trusted devices
</button>
)}
<button onClick={resetMfa} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
Reset two-factor
</button>
</div>
{msg && <p className="sans" style={{ marginTop: 12, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 12, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</section>
)
}
function ShardSections({ scope }) {
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}
export default function UserDetail() {
const { id } = useParams()
// Memoize so the child components' effects (keyed on `scope`) don't refetch
// on every render.
const scope = useMemo(() => api.admin.userShard(id), [id])
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load this user." />
return (
<section>
<Link to="/admin/users" className="link-accent" style={{ fontSize: '0.85rem' }}>
Back to users
</Link>
{/* Header */}
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 24px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
{user.username}
</span>
<span className={`badge ${ROLE_BADGE[user.role] || 'badge-editor'}`}>{user.role}</span>
<span
className="sans"
style={{ fontSize: '0.82rem', color: user.status && user.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
>
{user.status || 'active'}
</span>
</div>
<div className="sans dim" style={{ display: 'flex', gap: 18, marginTop: 10, flexWrap: 'wrap', fontSize: '0.8rem' }}>
{user.email && <span>{user.email}</span>}
<span>Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'}</span>
{user.created_at && <span>Joined: {dateTime(user.created_at)}</span>}
</div>
</div>
<SecurityAdmin userId={id} />
<ShardSections scope={scope} />
</section>
)
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js' import { useAsync } from '../../../lib/useAsync.js'
import { dateTime } from '../../../lib/format.js' import { dateTime } from '../../../lib/format.js'
@@ -13,6 +14,7 @@ const ROLE_BADGE = {
} }
export default function UsersAdmin() { export default function UsersAdmin() {
const navigate = useNavigate()
const [tick, setTick] = useState(0) const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), []) const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick]) const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
@@ -64,8 +66,13 @@ export default function UsersAdmin() {
</td> </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 style={{ display: 'inline-flex', gap: 16, justifyContent: 'flex-end' }}>
Edit <span className="link-accent" onClick={() => navigate(`/admin/users/${u.id}`)}>
View
</span>
<span className="link-accent" onClick={() => setEditing(u)}>
Edit
</span>
</span> </span>
</td> </td>
</tr> </tr>

View File

@@ -111,6 +111,9 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
} }
} }
let saveLabel = form.published ? 'Save & publish' : 'Save draft'
if (busy) saveLabel = 'Saving…'
return ( return (
<> <>
<Modal <Modal
@@ -133,7 +136,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
Cancel Cancel
</button> </button>
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq"> <button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'} {saveLabel}
</button> </button>
</> </>
} }

View File

@@ -80,11 +80,11 @@ export default function WikiHistory({ slug, onClose, onRestored }) {
</> </>
} }
> >
{loading ? ( {loading && <span className="spin" />}
<span className="spin" /> {!loading && error && (
) : error ? (
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p> <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
) : ( )}
{!loading && !error && (
<div className="wiki-history"> <div className="wiki-history">
<ul className="wiki-history-list"> <ul className="wiki-history-list">
{revisions.map((r, i) => ( {revisions.map((r, i) => (
@@ -122,11 +122,16 @@ export default function WikiHistory({ slug, onClose, onRestored }) {
{parts.length === 0 || (parts.length === 1 && !parts[0].added && !parts[0].removed) ? ( {parts.length === 0 || (parts.length === 1 && !parts[0].added && !parts[0].removed) ? (
<span className="muted">No textual differences.</span> <span className="muted">No textual differences.</span>
) : ( ) : (
parts.map((p, i) => ( parts.map((p, i) => {
<span key={i} className={p.added ? 'diff-add' : p.removed ? 'diff-del' : ''}> let cls = ''
{p.value} if (p.added) cls = 'diff-add'
</span> else if (p.removed) cls = 'diff-del'
)) return (
<span key={`${i}:${p.value}`} className={cls}>
{p.value}
</span>
)
})
)} )}
</div> </div>
</> </>

View File

@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx'
// Public, token-gated invite acceptance (/invite/:token). Validates the invite,
// lets the invitee set a username + password (their email + role are pre-assigned),
// creates the account at that role and logs them in. For a player invite it then
// offers the built-in "create game account" step before sending them to the portal.
export default function AcceptInvite() {
const { token } = useParams()
const navigate = useNavigate()
const { refresh } = useAuth()
const [invite, setInvite] = useState(null) // fields email and role
const [loadErr, setLoadErr] = useState('')
const [signupOk, setSignupOk] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [company, setCompany] = useState('') // honeypot
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [accepted, setAccepted] = useState(false)
useEffect(() => {
let active = true
api.getInvite(token)
.then((iv) => active && setInvite(iv))
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false }
}, [token])
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
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 api.acceptInvite(token, username.trim(), password, { company })
await refresh() // pull the freshly-issued session into context
setAccepted(true)
// Staff invites are web-only — no game step; go straight in.
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true })
} catch (err) {
if (err.status === 409) setError('That username is already taken, or the invite was already used.')
else if (err.status === 404) setError('This invitation is invalid or has expired.')
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
else setError('Could not accept the invitation right now.')
setBusy(false)
}
}
// ── Loading / invalid ─────────────────────────────────────────────────────
if (loadErr) {
return (
<PlayerShell subtitle="Invitation">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>{loadErr}</p>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Go to sign in</Link>
</p>
</PlayerShell>
)
}
if (!invite) {
return (
<PlayerShell subtitle="Invitation">
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}><span className="spin" /></div>
</PlayerShell>
)
}
// ── Accepted: optional game-account step (player invites) ──────────────────
if (accepted) {
return (
<PlayerShell subtitle="Set up your game account">
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
</p>
<CreateGameAccountForm
submit={api.player.shard.createAccount}
onCreated={() => navigate('/player', { replace: true })}
/>
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
<button type="button" onClick={() => navigate('/player', { replace: true })} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
Skip for now
</button>
</p>
</PlayerShell>
)
}
// ── Accept form ────────────────────────────────────────────────────────────
return (
<PlayerShell subtitle="Accept your invitation">
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Youve been invited as <strong style={{ color: 'var(--head)' }}>{invite.role}</strong>
{invite.email ? <> for <strong style={{ color: 'var(--head)' }}>{invite.email}</strong></> : null}. Choose a username and password to finish.
</p>
<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: 22 }}>
<span className="field-label">Password</span>
<input type="password" autoComplete="new-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>
{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…' : 'Accept & create account'}
</button>
</form>
</PlayerShell>
)
}

View File

@@ -0,0 +1,74 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../api/client.js'
import PlayerShell from './PlayerShell.jsx'
// Public "forgot password" request page. Submitting emails a tokened reset link to
// every active account on the address (see ResetPassword for the other half). The
// server never reveals whether the email exists — it always answers the same way —
// so this page shows an identical confirmation regardless, to avoid enumeration.
export default function ForgotPassword() {
const [email, setEmail] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [sent, setSent] = useState(false)
async function onSubmit(e) {
e.preventDefault()
setError('')
if (!/.+@.+\..+/.test(email.trim())) return setError('Enter a valid email address.')
setBusy(true)
try {
await api.forgotPassword(email.trim())
setSent(true)
} catch (err) {
// Only a rate-limit (429) or a real outage surfaces here — a non-match still
// returns 200. Keep the message generic either way.
if (err.status === 429) setError('Too many requests. Please try again in a little while.')
else setError('Could not send the reset email right now. Please try again later.')
setBusy(false)
}
}
if (sent) {
return (
<PlayerShell subtitle="Reset your password">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', lineHeight: 1.6, textAlign: 'center' }}>
If an account exists for <strong style={{ color: 'var(--head)' }}>{email.trim()}</strong>, weve sent a link to
reset its password. Check your inbox (and spam) the link expires in about an hour.
</p>
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Back to sign in</Link>
</p>
</PlayerShell>
)
}
return (
<PlayerShell
subtitle="Reset your password"
footer={
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
Remembered it?{' '}
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Sign in</Link>
</p>
}
>
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Enter the email on your account and well send you a link to choose a new password.
</p>
<form onSubmit={onSubmit}>
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Email</span>
<input type="email" autoComplete="email" autoFocus value={email} onChange={(e) => setEmail(e.target.value)} className="input" />
</label>
{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 ? 'Sending…' : 'Send reset link'}
</button>
</form>
</PlayerShell>
)
}

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import ProviderIcon from '../../components/ProviderIcon.jsx' import ProviderIcon from '../../components/ProviderIcon.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx'
import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
@@ -75,6 +78,9 @@ function ChangePassword({ account }) {
} }
} }
let pwLabel = hasPassword ? 'Change password' : 'Set password'
if (busy) pwLabel = 'Saving…'
return ( return (
<Section title={hasPassword ? 'Password' : 'Set a password'}> <Section title={hasPassword ? 'Password' : 'Set a password'}>
{!hasPassword && ( {!hasPassword && (
@@ -96,7 +102,7 @@ function ChangePassword({ account }) {
</label> </label>
<div> <div>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq"> <button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : hasPassword ? 'Change password' : 'Set password'} {pwLabel}
</button> </button>
</div> </div>
<Note msg={msg} error={error} /> <Note msg={msg} error={error} />
@@ -113,6 +119,7 @@ function TwoFactor({ account, reload }) {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
async function begin() { async function begin() {
setBusy(true); setMsg(''); setError('') setBusy(true); setMsg(''); setError('')
@@ -128,8 +135,8 @@ function TwoFactor({ account, reload }) {
async function confirm() { async function confirm() {
setBusy(true); setMsg(''); setError('') setBusy(true); setMsg(''); setError('')
try { try {
await api.player.totpEnable(code.trim()) const res = await api.player.totpEnable(code.trim())
setSetup(null); setCode(''); setMsg('Two-factor is now enabled.') setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.')
await reload() await reload()
} catch (err) { } catch (err) {
setError(err.message || 'Could not enable two-factor.') setError(err.message || 'Could not enable two-factor.')
@@ -201,6 +208,11 @@ function TwoFactor({ account, reload }) {
</div> </div>
)} )}
<Note msg={msg} error={error} /> <Note msg={msg} error={error} />
{newCodes && (
<div style={{ marginTop: 16 }}>
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
</div>
)}
</Section> </Section>
) )
} }
@@ -295,6 +307,73 @@ function LinkedAccounts() {
) )
} }
// ── Active mobile device sessions ──────────────────────────────────────────
function ActiveDevices() {
const [sessions, setSessions] = useState(null)
const [error, setError] = useState('')
const [busyId, setBusyId] = useState(null)
const load = useCallback(async () => {
try {
setSessions(await api.mySessions())
} catch {
setError('Could not load your devices.')
}
}, [])
useEffect(() => { load() }, [load])
async function revoke(id) {
if (!window.confirm('Sign this device out? It will need to sign in again.')) return
setBusyId(id)
try {
await api.revokeMySession(id)
await load()
} catch (err) {
setError(err.message || 'Could not sign that device out.')
} finally {
setBusyId(null)
}
}
const fmt = (d) => {
const t = d ? new Date(d) : null
return t && !Number.isNaN(t.getTime()) ? t.toLocaleString() : '—'
}
if (error) return (
<Section title="Active devices"><ErrorState message={error} /></Section>
)
if (!sessions) return null
return (
<Section title="Active devices">
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Devices signed in to the mobile app. Sign one out to revoke its access it may keep working for
a few minutes until its current token expires.
</p>
{sessions.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.86rem' }}>No mobile devices are signed in.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{sessions.map((s) => (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{s.deviceName || s.userAgent || 'Mobile device'}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem' }}>Last active {fmt(s.lastUsedAt)}</div>
</div>
<button onClick={() => revoke(s.id)} disabled={busyId === s.id} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
{busyId === s.id ? 'Signing out…' : 'Sign out'}
</button>
</div>
))}
</div>
)}
</Section>
)
}
// ── Shared bits ──────────────────────────────────────────────────────────── // ── Shared bits ────────────────────────────────────────────────────────────
function Section({ title, children }) { function Section({ title, children }) {
return ( return (
@@ -346,7 +425,14 @@ export default function PlayerAccount() {
<ChangeUsername account={account} onChanged={onUsernameChanged} /> <ChangeUsername account={account} onChanged={onUsernameChanged} />
<ChangePassword account={account} /> <ChangePassword account={account} />
<TwoFactor account={account} reload={load} /> <TwoFactor account={account} reload={load} />
{account.totp_enabled && (
<>
<TrustedDevicesPanel />
<RecoveryCodesPanel hasPassword={account.has_password !== false} />
</>
)}
<LinkedAccounts /> <LinkedAccounts />
<ActiveDevices />
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,221 @@
import { useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { ago, dateTime } from '../../lib/format.js'
import { api } from '../../api/client.js'
// Player-facing appeals: eligible sanctions the player can appeal, plus the
// status of appeals they've already submitted. Mirrors PlayerAccount's
// Section layout.
const STATUS_STYLE = {
pending: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
under_review: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
approved: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
denied: { color: '#d98b84', background: 'rgba(217,139,132,0.16)', border: '1px solid rgba(217,139,132,0.4)' },
withdrawn: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
}
const STATUS_LABEL = {
pending: 'Pending',
under_review: 'Under review',
approved: 'Approved',
denied: 'Denied',
withdrawn: 'Withdrawn',
}
function fmtDuration(seconds) {
if (!seconds) return null
if (seconds % 86400 === 0) return `${seconds / 86400}d`
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
return `${seconds}s`
}
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 EligibleItem({ item, onSubmitted }) {
const [open, setOpen] = useState(false)
const [text, setText] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function submit() {
if (!text.trim()) return
setBusy(true)
setError('')
try {
await api.player.submitAppeal({ mod_action_id: item.id, submitted_text: text.trim() })
setText('')
setOpen(false)
onSubmitted()
} catch (err) {
setError(err.message || 'Could not submit your appeal.')
} finally {
setBusy(false)
}
}
const duration = fmtDuration(item.duration_seconds)
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span className={`badge badge-${item.action_type}`}>{item.action_type}</span>
{duration && <span className="sans dim" style={{ fontSize: '0.78rem' }}>{duration}</span>}
<span className="sans dim" style={{ fontSize: '0.78rem', marginLeft: 'auto' }} title={dateTime(item.created_at)}>
{ago(item.created_at)}
</span>
</div>
<p className="sans" style={{ margin: '10px 0 0', color: 'var(--text)', fontSize: '0.88rem' }}>
{item.reason || 'No reason given.'}
</p>
{!open ? (
<div style={{ marginTop: 12 }}>
<button onClick={() => setOpen(true)} className="btn btn-primary btn-sq">
Appeal this
</button>
</div>
) : (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
<textarea
className="textarea"
placeholder="Explain why this action should be reversed…"
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={submit} disabled={busy || !text.trim()} className="btn btn-primary btn-sq">
{busy ? 'Submitting…' : 'Submit appeal'}
</button>
<button onClick={() => { setOpen(false); setError('') }} disabled={busy} className="pill">
Cancel
</button>
</div>
</div>
)}
</div>
)
}
function EligibleAppeals({ items, onSubmitted }) {
if (items.length === 0) {
return (
<div>
<p className="sans dim" style={{ fontSize: '0.88rem' }}>You have no sanctions available to appeal right now.</p>
<p className="sans dim" style={{ fontSize: '0.82rem' }}>
If you were sanctioned on Discord, link your Discord account on the{' '}
<Link to="/account" className="link-accent">Account</Link> page to appeal.
</p>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{items.map((item) => (
<EligibleItem key={item.id} item={item} onSubmitted={onSubmitted} />
))}
</div>
)
}
function MyAppealItem({ appeal, onWithdrawn }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const canWithdraw = appeal.status === 'pending' || appeal.status === 'under_review'
async function withdraw() {
setBusy(true)
setError('')
try {
await api.player.withdrawAppeal(appeal.id)
onWithdrawn()
} catch (err) {
setError(err.message || 'Could not withdraw this appeal.')
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span className={`badge badge-${appeal.action_type}`}>{appeal.action_type}</span>
<span className="badge" style={STATUS_STYLE[appeal.status]}>{STATUS_LABEL[appeal.status] || appeal.status}</span>
<span className="sans dim" style={{ fontSize: '0.78rem', marginLeft: 'auto' }} title={dateTime(appeal.submitted_at)}>
{ago(appeal.submitted_at)}
</span>
</div>
<p className="sans" style={{ margin: '10px 0 0', color: 'var(--text)', fontSize: '0.88rem', whiteSpace: 'pre-wrap' }}>
{appeal.submitted_text}
</p>
{appeal.staff_response && (
<div style={{ marginTop: 10, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div className="field-label" style={{ marginBottom: 4 }}>Staff response</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', whiteSpace: 'pre-wrap' }}>{appeal.staff_response}</p>
</div>
)}
{error && <p className="sans" style={{ margin: '10px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{canWithdraw && (
<div style={{ marginTop: 12 }}>
<button onClick={withdraw} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
{busy ? 'Withdrawing…' : 'Withdraw'}
</button>
</div>
)}
</div>
)
}
function MyAppeals({ appeals, onChange }) {
if (appeals.length === 0) {
return <p className="sans dim" style={{ fontSize: '0.88rem' }}>You haven't submitted any appeals yet.</p>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{appeals.map((a) => (
<MyAppealItem key={a.id} appeal={a} onWithdrawn={onChange} />
))}
</div>
)
}
export default function PlayerAppeals() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(
() => Promise.all([api.player.getEligibleAppeals(), api.player.getMyAppeals()]),
[tick],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load your appeals." />
const [eligible, mine] = data
return (
<div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Appeal a Discord ban or mute, or check the status of an appeal you've already submitted.
</p>
<Section title="Appealable sanctions">
<EligibleAppeals items={eligible} onSubmitted={reload} />
</Section>
<Section title="My appeals">
<MyAppeals appeals={mine} onChange={reload} />
</Section>
</div>
)
}

View File

@@ -1,14 +1,57 @@
import GameAccounts from '../../components/GameAccounts.jsx' import GameAccounts from '../../components/GameAccounts.jsx'
import VendorSales from '../../components/VendorSales.jsx' import VendorSales from '../../components/VendorSales.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
// The logged-in player's characters. Shows the link prompt when no game account // The logged-in player's characters. Shows the link prompt when no game account
// is linked, otherwise their characters grouped by account (shared component), // is linked, otherwise their characters grouped by account (shared component),
// plus their own recent vendor sales. // plus their own home status and recent vendor sales.
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
// The caller's own houses (home status). Only their own — never anyone else's.
function MyHouses() {
const { data } = useAsync(() => api.player.shard.houses(), [])
if (!data || data.length === 0) return null
return (
<section style={{ marginTop: 30 }}>
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => {
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
return (
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
{label && (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
{label}
</span>
)}
</div>
)
})}
</div>
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
Keep an eye on the decay status refresh a house in game before it reaches IDOC.
</p>
</section>
)
}
export default function PlayerCharacters() { export default function PlayerCharacters() {
return ( return (
<div> <div>
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} /> <GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
<MyHouses />
<VendorSales fetchSales={api.player.shard.sales} /> <VendorSales fetchSales={api.player.shard.sales} />
</div> </div>
) )

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom' import { Link, useNavigate, useLocation } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx' import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx' import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
@@ -34,6 +35,12 @@ export default function PlayerLogin() {
const [challenge, setChallenge] = useState('') const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false) const [ssoTotp, setSsoTotp] = useState(false)
const [trustDevice, setTrustDevice] = useState(false)
const [useRecovery, setUseRecovery] = useState(false)
// When trust was requested at login but the device cap is reached: show the
// revoke-to-continue modal, then navigate on resolve. `pendingDest` holds where
// to go once the prompt is dealt with.
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest }
const [providers, setProviders] = useState([]) const [providers, setProviders] = useState([])
const [canRegister, setCanRegister] = useState(false) const [canRegister, setCanRegister] = useState(false)
@@ -101,15 +108,35 @@ export default function PlayerLogin() {
setBusy(true) setBusy(true)
try { try {
if (ssoTotp) { if (ssoTotp) {
const { returnTo } = await ssoLoginTotp(code) const { returnTo, redirect } = await ssoLoginTotp(code)
// Native SSO bridge (M9): a mobile 2FA completion returns an absolute
// deep link (e.g. runicgateway://…) to hand the app its one-time code.
// React Router can't navigate a custom scheme, so leave the SPA for it.
if (redirect) {
window.location.href = redirect
return
}
navigate(returnTo || '/account', { replace: true }) navigate(returnTo || '/account', { replace: true })
} else { } else {
const u = await loginTotp(challenge, code) const entered = code.trim()
navigate(destFor(u), { replace: true }) const data = await loginTotp(challenge, useRecovery ? '' : entered, {
recoveryCode: useRecovery ? entered : undefined,
trustDevice,
})
const to = destFor(data.user)
// Trust was requested but the device cap is reached: the session is already
// issued, so prompt to revoke one before trusting, then navigate.
if (data.trustLimitReached) {
setTrustLimit({ devices: data.devices || [], dest: to })
setBusy(false)
return
}
navigate(to, { 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)
setError(expired ? 'Your verification session expired. Please sign in again.' : 'Invalid verification code.') const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
setBusy(false) setBusy(false)
if (expired) { if (expired) {
setStage('creds') setStage('creds')
@@ -118,18 +145,29 @@ export default function PlayerLogin() {
} }
} }
let submitLabel = 'Sign in'
if (busy) submitLabel = 'Signing in…'
else if (stage === 'totp') submitLabel = 'Verify'
return ( return (
<PlayerShell <PlayerShell
subtitle="Player sign-in" subtitle="Player sign-in"
footer={ footer={
canRegister && ( <div style={{ margin: '16px 0 0', textAlign: 'center' }}>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}> <p className="sans" style={{ margin: 0, color: 'var(--dim)', fontSize: '0.84rem' }}>
New here?{' '} <Link to="/account/forgot" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}> Forgot your password?
Create an account
</Link> </Link>
</p> </p>
) {canRegister && (
<p className="sans" style={{ margin: '8px 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>
)}
</div>
} }
> >
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}> <form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
@@ -151,13 +189,42 @@ export default function PlayerLogin() {
</div> </div>
</> </>
) : ( ) : (
<label style={{ display: 'block', marginBottom: 22 }}> <>
<span className="field-label">Authentication code</span> <label style={{ display: 'block', marginBottom: 14 }}>
<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="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}> <input
Enter the code from your authenticator app. type="text"
</span> inputMode={useRecovery ? 'text' : 'numeric'}
</label> autoComplete="one-time-code"
autoFocus
placeholder={useRecovery ? 'xxxxx-xxxxx' : '6-digit code'}
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
</span>
</label>
{/* Trust-this-device only applies to real authenticator/recovery login,
not the SSO 2FA bounce (which has no trust cookie flow here). */}
{!ssoTotp && (
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
Trust this device for 30 days (skip the code next time)
</label>
)}
{!ssoTotp && (
<button
type="button"
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
className="sans"
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
>
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
</button>
)}
</>
)} )}
{(error || (stage === 'creds' && ssoError)) && ( {(error || (stage === 'creds' && ssoError)) && (
@@ -167,7 +234,7 @@ export default function PlayerLogin() {
)} )}
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}> <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'} {submitLabel}
</button> </button>
{stage === 'creds' && providers.length > 0 && ( {stage === 'creds' && providers.length > 0 && (
@@ -190,6 +257,14 @@ export default function PlayerLogin() {
</div> </div>
)} )}
</form> </form>
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
onCancel={() => navigate(trustLimit.dest, { replace: true })}
/>
)}
</PlayerShell> </PlayerShell>
) )
} }

View File

@@ -1,6 +1,7 @@
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'
// Shared shell for the logged-in player portal. Uses the same sidebar shell as // Shared shell for the logged-in player portal. Uses the same sidebar shell as
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in // Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
@@ -27,10 +28,12 @@ function Icon({ children, size = 16 }) {
} }
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon> const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></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 IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const NAV = [ const NAV = [
{ to: '/player', label: 'Characters', end: true, icon: IconUser }, { to: '/player', label: 'Characters', end: true, icon: IconUser },
{ to: '/account', label: 'Account', icon: IconGear }, { to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
] ]
// The sticky content header mirrors the active page. Character sheets live under // The sticky content header mirrors the active page. Character sheets live under
@@ -38,6 +41,7 @@ const NAV = [
const TITLES = { const TITLES = {
'/player': 'Characters', '/player': 'Characters',
'/account': 'Account', '/account': 'Account',
'/account/appeals': 'Appeals',
} }
const navBtnBase = { const navBtnBase = {
@@ -55,6 +59,7 @@ const navBtnBase = {
export default function PlayerPortalLayout() { export default function PlayerPortalLayout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { siteTitle } = useSite()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = const title =
@@ -84,7 +89,7 @@ export default function PlayerPortalLayout() {
<MoonDot /> <MoonDot />
<div> <div>
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}> <div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
UOMysticmoon {siteTitle}
</div> </div>
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}> <div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
Player Portal Player Portal

View File

@@ -75,15 +75,17 @@ export default function PlayerRegister() {
</p> </p>
} }
> >
{avail === null ? ( {avail === null && (
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}> <div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
<span className="spin" /> <span className="spin" />
</div> </div>
) : closed ? ( )}
{avail !== null && closed && (
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem', textAlign: 'center', lineHeight: 1.6 }}> <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. Self-registration is currently closed. Please check back later.
</p> </p>
) : ( )}
{avail !== null && !closed && (
<> <>
{avail.password && ( {avail.password && (
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>

View File

@@ -1,12 +1,12 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import { useSite } from '../../contexts/SiteContext.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` // 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). // labels the card; `footer` is optional content under the card (e.g. cross-links).
export default function PlayerShell({ subtitle, children, footer }) { export default function PlayerShell({ subtitle, children, footer }) {
const { siteTitle, heroImage } = useSite()
const bg = `linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('${heroImage}')`
return ( return (
<main <main
style={{ style={{
@@ -16,9 +16,10 @@ export default function PlayerShell({ subtitle, children, footer }) {
padding: '40px 18px', padding: '40px 18px',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'var(--bg-deep)', backgroundColor: 'var(--bg-deep)',
backgroundImage: BG, backgroundImage: bg,
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundSize: 'cover', backgroundRepeat: 'no-repeat',
backgroundSize: 'min(60vh, 520px)',
}} }}
> >
<div style={{ width: '100%', maxWidth: 400 }}> <div style={{ width: '100%', maxWidth: 400 }}>
@@ -27,7 +28,7 @@ export default function PlayerShell({ subtitle, children, footer }) {
<MoonDot size={15} glow={0.55} /> <MoonDot size={15} glow={0.55} />
</div> </div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}> <h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
UOMysticmoon {siteTitle}
</h1> </h1>
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}> <p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
{subtitle} {subtitle}

View File

@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../../api/client.js'
import PlayerShell from './PlayerShell.jsx'
// Public, token-gated reset page (/account/reset/:token). Validates the link, lets
// the user choose a new password, then sends them to sign in fresh. Setting the
// password revokes every existing session (web + mobile) server-side and does NOT
// log them in here — so a 2FA account still passes TOTP on the next sign-in.
export default function ResetPassword() {
const { token } = useParams()
const navigate = useNavigate()
const [username, setUsername] = useState(null) // whose account this link is for
const [loadErr, setLoadErr] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
let active = true
api.getPasswordReset(token)
.then((r) => active && setUsername(r?.username || ''))
.catch((err) => active && setLoadErr(
err.status === 404 ? 'This reset link is invalid or has expired.' : 'Could not load this reset link.',
))
return () => { active = false }
}, [token])
async function onSubmit(e) {
e.preventDefault()
setError('')
if (password.length < 8) return setError('Password must be at least 8 characters.')
if (password !== confirm) return setError('The passwords do not match.')
setBusy(true)
try {
await api.resetPassword(token, password)
setDone(true)
} catch (err) {
if (err.status === 404) setError('This reset link is invalid or has already been used.')
else if (err.status === 429) setError('Too many attempts. Please try again in a little while.')
else if (err.status === 400) setError(err.message || 'Please check your password and try again.')
else setError('Could not reset your password right now. Please try again later.')
setBusy(false)
}
}
// ── Invalid link ───────────────────────────────────────────────────────────
if (loadErr) {
return (
<PlayerShell subtitle="Reset your password">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>{loadErr}</p>
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
<Link to="/account/forgot" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Request a new link</Link>
</p>
</PlayerShell>
)
}
if (username === null) {
return (
<PlayerShell subtitle="Reset your password">
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}><span className="spin" /></div>
</PlayerShell>
)
}
// ── Done ───────────────────────────────────────────────────────────────────
if (done) {
return (
<PlayerShell subtitle="Password updated">
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
Your password has been reset. For your security, every existing session has been signed out.
</p>
<button
type="button"
onClick={() => navigate('/account/login', { replace: true })}
className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center', marginTop: 20 }}
>
Sign in
</button>
</PlayerShell>
)
}
// ── Reset form ─────────────────────────────────────────────────────────────
return (
<PlayerShell subtitle="Reset your password">
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose a new password{username ? <> for <strong style={{ color: 'var(--head)' }}>{username}</strong></> : null}.
</p>
<form onSubmit={onSubmit}>
{/* A hidden username field helps password managers associate the credential. */}
{username ? <input type="text" name="username" autoComplete="username" value={username} readOnly hidden /> : null}
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">New password</span>
<input type="password" autoComplete="new-password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
</label>
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Confirm new password</span>
<input type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} className="input" />
</label>
{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 ? 'Saving…' : 'Set new password'}
</button>
</form>
</PlayerShell>
)
}

Some files were not shown because too many files have changed in this diff Show More