Compare commits
106 Commits
3e6ecb959a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5103b74a9d | |||
| c91fd128bf | |||
| 01a559792c | |||
| e50fab241f | |||
| 779a304173 | |||
| c6c0c257dd | |||
| 8771a1cf6c | |||
| 8da658f223 | |||
| bda031566a | |||
| b61a4d6721 | |||
| 1e1a3d67c3 | |||
| 26094459ae | |||
| bfa1db58c4 | |||
| 7c769ea8fd | |||
| f3d084e046 | |||
| a4ef9d676d | |||
| 2801ec8f4d | |||
| 353cce9f26 | |||
| 7b98f1a778 | |||
| 61d6bfaca2 | |||
| 6b1396dd2f | |||
| f30ea66fce | |||
| cd56af3f12 | |||
| f3450686e0 | |||
| a3407ae654 | |||
| 620781b7bc | |||
| f6611231c4 | |||
| a6fd5659c4 | |||
| 068844bfd9 | |||
| 565a7d2c20 | |||
| 3fcc64ab96 | |||
| 8fd0d82580 | |||
| 812b895507 | |||
| 00ad16858a | |||
| 493843241e | |||
| bd53a0b8a4 | |||
| 0e11e28cca | |||
| f7c98b8ba3 | |||
| 8ad892725f | |||
| 1a61cd1638 | |||
| 0dc5af0d8b | |||
| 9b74999610 | |||
| 49b70ee04d | |||
| 1079b3fc05 | |||
| cbe54fcc91 | |||
| 9f9bcc6f6e | |||
| ebfae765d9 | |||
| c075ab981c | |||
| bcdba4ce0a | |||
| e08c0c9736 | |||
| 5fe7032567 | |||
| 4151f7d44e | |||
| 4f1a4902e8 | |||
| 14dfc122ba | |||
| 514bc9d23c | |||
| 60ebacff2c | |||
| 8d5bdc0d6e | |||
| c991a07c8a | |||
| 4c13706958 | |||
| 2306545574 | |||
| 70849f96ee | |||
| 1edef8e6db | |||
| dc90df9fff | |||
| 68126efc0b | |||
| 401db8f75c | |||
| 9b0f2d93d8 | |||
| 12d50fd615 | |||
| 4993470fa2 | |||
| 2f3e8f7df5 | |||
| 68a4e9295b | |||
| 886a504152 | |||
| 35e5269ec5 | |||
| 99fd9acddb | |||
| b474303052 | |||
| e515fc0c9d | |||
| c73273f1bc | |||
| 5e5e0d7a91 | |||
| e1461d9161 | |||
| 82bf2c972c | |||
| c54bb54834 | |||
| 86420661b5 | |||
| d1b3351360 | |||
| dacc1bd4f7 | |||
| bcc96e7cfb | |||
| d37c3a46a9 | |||
| e3dd5358b6 | |||
| 61f4591a6b | |||
| 31b72859ce | |||
| a789ee3ac9 | |||
| 4fa73d3ccf | |||
| 416761f8f7 | |||
| 030414f13d | |||
| c35509e8b3 | |||
| 90c8eae20f | |||
| 93a2c0d55f | |||
| fc5255da99 | |||
| 715eaedd74 | |||
| 250cb1e2d3 | |||
| 10aed49bb6 | |||
| 896773b8a5 | |||
| 60e6a60842 | |||
| 9ac1f35fa0 | |||
| 028ba8c5e4 | |||
| 5f09ab1146 | |||
| b0549f5845 | |||
| aa2177715e |
27
.env.example
27
.env.example
@@ -117,4 +117,29 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
|||||||
# token). These URLs are just defaults; the admin can override them at runtime.
|
# token). These URLs are just defaults; the admin can override them at runtime.
|
||||||
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
|
# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site
|
||||||
|
# with nothing saved yet — the admin panel's pinned value wins — but set it lower
|
||||||
|
# if you deliberately run an older sidecar.
|
||||||
|
UOLINK_PROTOCOL=3
|
||||||
|
|
||||||
|
# ─── 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
|
||||||
|
|||||||
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
|
||||||
|
|
||||||
|
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
|
||||||
|
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
|
||||||
|
|
||||||
|
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
|
||||||
|
|
||||||
|
Deterministic ordering: directories before files, each group sorted
|
||||||
|
case-insensitively with the raw name as a tiebreak. Output uses the classic
|
||||||
|
`tree(1)` box-drawing style so the result is stable across runs and platforms.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def build(paths):
|
||||||
|
root = {}
|
||||||
|
for p in paths:
|
||||||
|
p = p.strip().replace("\\", "/")
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
node = root
|
||||||
|
for part in p.split("/"):
|
||||||
|
node = node.setdefault(part, {})
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def render(node, prefix, lines):
|
||||||
|
entries = list(node.items())
|
||||||
|
# directories (non-empty children dict) before files, then case-insensitive name
|
||||||
|
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
|
||||||
|
for i, (name, child) in enumerate(entries):
|
||||||
|
last = i == len(entries) - 1
|
||||||
|
branch = "└── " if last else "├── "
|
||||||
|
suffix = "/" if child else ""
|
||||||
|
lines.append(f"{prefix}{branch}{name}{suffix}")
|
||||||
|
if child:
|
||||||
|
render(child, prefix + (" " if last else "│ "), lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||||
|
tree = build(sys.stdin.read().splitlines())
|
||||||
|
lines = [f"{root_label}/"]
|
||||||
|
render(tree, "", lines)
|
||||||
|
sys.stdout.write("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -40,6 +40,13 @@ jobs:
|
|||||||
run: npm ci --prefix server
|
run: npm ci --prefix server
|
||||||
- name: Run server tests
|
- name: Run server tests
|
||||||
run: npm test --prefix server
|
run: npm test --prefix server
|
||||||
|
- name: Check the route manifest is current
|
||||||
|
# The URL surface is frozen while the routers are carved up by capability
|
||||||
|
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
|
||||||
|
# stack and diffing proves a "mechanical" refactor moved no URL. A PR that
|
||||||
|
# really does change one has to commit the new manifest, putting it in front
|
||||||
|
# of a reviewer instead of letting it pass silently.
|
||||||
|
run: npm run routes:manifest --prefix server -- --check
|
||||||
|
|
||||||
client-build:
|
client-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -52,6 +59,9 @@ jobs:
|
|||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: client/package-lock.json
|
||||||
- name: Install client deps
|
- name: Install client deps
|
||||||
run: npm ci --prefix client
|
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
|
- name: Build client
|
||||||
run: npm run build --prefix client
|
run: npm run build --prefix client
|
||||||
|
|
||||||
|
|||||||
87
.gitea/workflows/sonarqube.yml
Normal file
87
.gitea/workflows/sonarqube.yml
Normal 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 }}
|
||||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
name: sync-project-tree
|
||||||
|
|
||||||
|
# Keeps this repo's file-layout snapshot (docs/website/PROJECT_TREE.md in the
|
||||||
|
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
|
||||||
|
# tree from tracked files and, if it changed, opens (or force-updates) a pull
|
||||||
|
# request against the docs repo. It never writes to the docs repo's `main`
|
||||||
|
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
|
||||||
|
# other workflows use (the token needs repo read/write on RunicGateway/docs).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sync-project-tree
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
|
DOCS_REPO: RunicGateway/docs
|
||||||
|
SELF_REPO: RunicGateway/website
|
||||||
|
DOCS_PATH: website/PROJECT_TREE.md
|
||||||
|
TREE_TITLE: Website
|
||||||
|
ROOT_LABEL: website
|
||||||
|
PR_BRANCH: chore/sync-website-tree
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out this repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Ensure python3 is available
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
|
||||||
|
|
||||||
|
- name: Render PROJECT_TREE.md from tracked files
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p _sync
|
||||||
|
{
|
||||||
|
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
|
||||||
|
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
|
||||||
|
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
|
||||||
|
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
|
||||||
|
printf '> by hand — changes will be overwritten by the next sync.\n\n'
|
||||||
|
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
|
||||||
|
printf 'git-ignored paths are excluded).\n\n'
|
||||||
|
printf '```text\n'
|
||||||
|
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
|
||||||
|
printf '```\n'
|
||||||
|
} > _sync/PROJECT_TREE.md
|
||||||
|
echo "----- generated ${DOCS_PATH} -----"
|
||||||
|
cat _sync/PROJECT_TREE.md
|
||||||
|
|
||||||
|
- name: Open or update the docs PR if the tree changed
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Secrets can carry a trailing CR/LF depending on how they were pasted;
|
||||||
|
# strip line breaks before they land in a URL or Authorization header.
|
||||||
|
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
|
||||||
|
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
|
||||||
|
|
||||||
|
git clone --depth 1 "${REMOTE}" docs_repo
|
||||||
|
cd docs_repo
|
||||||
|
git config user.name "runic-docs-bot"
|
||||||
|
git config user.email "ci@whitlocktech.com"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${DOCS_PATH}")"
|
||||||
|
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
|
||||||
|
git add "${DOCS_PATH}"
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "PROJECT_TREE.md already up to date — nothing to sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
|
||||||
|
git checkout -B "${PR_BRANCH}"
|
||||||
|
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
|
||||||
|
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
|
||||||
|
|
||||||
|
# Open a PR only if one isn't already open for this branch (a force-push
|
||||||
|
# to an existing open PR's head updates it in place).
|
||||||
|
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${API}/pulls?state=open&limit=50" \
|
||||||
|
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
|
||||||
|
if [ "${OPEN}" = "0" ]; then
|
||||||
|
curl -sSf -X POST "${API}/pulls" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg head "${PR_BRANCH}" \
|
||||||
|
--arg base "main" \
|
||||||
|
--arg title "docs(tree): sync ${DOCS_PATH}" \
|
||||||
|
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
|
||||||
|
'{head: $head, base: $base, title: $title, body: $body}')" \
|
||||||
|
>/dev/null
|
||||||
|
echo "Opened a new docs PR for ${PR_BRANCH}."
|
||||||
|
else
|
||||||
|
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
|
||||||
|
fi
|
||||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -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
|
||||||
@@ -17,6 +21,24 @@ uploads/
|
|||||||
server/logs/
|
server/logs/
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
|
||||||
|
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
|
||||||
|
# not ours to redistribute. The images live under server/uploads/atlas/, already
|
||||||
|
# ignored above; this is the slug -> file-name map pointing at them.
|
||||||
|
# See docs/website/SPAWN_ATLAS.md and db/data/spawnAtlas.art.example.json.
|
||||||
|
server/db/data/spawnAtlas.art.json
|
||||||
|
|
||||||
|
# Operator-supplied cliloc table. UO's localization strings are EA's, extracted
|
||||||
|
# from the operator's own client and converted once (docs/website/CLILOCS.md);
|
||||||
|
# the repo ships no string table, for the same reason it ships no artwork and no
|
||||||
|
# map snapshot. This covers the conventional in-repo location — the supported
|
||||||
|
# arrangement is a path OUTSIDE the repo, set from Admin → Shard.
|
||||||
|
server/db/data/cliloc*
|
||||||
|
server/db/data/clilocs.*
|
||||||
|
# The build output of tools/cliloc-export (a throwaway helper, not a package).
|
||||||
|
server/tools/cliloc-export/bin/
|
||||||
|
server/tools/cliloc-export/obj/
|
||||||
|
|
||||||
# reference material (extracted from the provided archives)
|
# reference material (extracted from the provided archives)
|
||||||
_reference/
|
_reference/
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ If you add or change an API route, regenerate the Swagger spec
|
|||||||
(`cd server && npm run swagger`) and commit the updated
|
(`cd server && npm run swagger`) and commit the updated
|
||||||
`server/swagger/swagger-output.json`.
|
`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
|
## Branch & PR workflow
|
||||||
|
|
||||||
1. Fork or branch from `main`. Use a descriptive branch name
|
1. Fork or branch from `main`. Use a descriptive branch name
|
||||||
|
|||||||
183
README.md
183
README.md
@@ -1,5 +1,13 @@
|
|||||||
# Runic Gateway Website
|
# Runic Gateway Website
|
||||||
|
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
|
||||||
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
|
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
|
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
|
||||||
[Branding](#branding)); **UOMysticmoon** is the first instance.
|
[Branding](#branding)); **UOMysticmoon** is the first instance.
|
||||||
@@ -8,7 +16,7 @@ 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 ([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).
|
- **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](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.
|
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.
|
||||||
@@ -17,6 +25,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
|
|||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
|
- [Architecture](#architecture)
|
||||||
- [Tech stack](#tech-stack)
|
- [Tech stack](#tech-stack)
|
||||||
- [Project structure](#project-structure)
|
- [Project structure](#project-structure)
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
@@ -32,7 +41,103 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
|
|||||||
- [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/ — 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,7 +151,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
|
|||||||
| 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, …) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -271,6 +376,35 @@ 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 — a route's path
|
||||||
|
sits on the line *after* `router.get(`, which defeats greps) and keeps only
|
||||||
|
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` 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)
|
||||||
@@ -355,7 +489,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
| `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` | `rg_token` | changing it on a live instance invalidates existing sessions |
|
| `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) |
|
| `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) |
|
||||||
@@ -460,7 +594,7 @@ run this repo as UOMysticmoon.
|
|||||||
**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.
|
||||||
@@ -487,13 +621,42 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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' }))
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
|||||||
import Guilds from './routes/public/Guilds.jsx'
|
import Guilds from './routes/public/Guilds.jsx'
|
||||||
import Governors from './routes/public/Governors.jsx'
|
import Governors from './routes/public/Governors.jsx'
|
||||||
import Houses from './routes/public/Houses.jsx'
|
import Houses from './routes/public/Houses.jsx'
|
||||||
|
import Rules from './routes/public/Rules.jsx'
|
||||||
|
import Atlas from './routes/public/Atlas.jsx'
|
||||||
|
import AtlasCreature from './routes/public/AtlasCreature.jsx'
|
||||||
|
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||||
|
import Market from './routes/public/Market.jsx'
|
||||||
|
import MarketVendor from './routes/public/MarketVendor.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'
|
||||||
@@ -40,6 +46,8 @@ 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 ShardVisibility from './routes/admin/views/ShardVisibility.jsx'
|
||||||
|
import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx'
|
||||||
import ShardOps from './routes/admin/views/ShardOps.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'
|
||||||
@@ -51,15 +59,19 @@ 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 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 (
|
||||||
@@ -93,6 +105,12 @@ export default function App() {
|
|||||||
<Route path="/site/guilds" element={<Guilds />} />
|
<Route path="/site/guilds" element={<Guilds />} />
|
||||||
<Route path="/site/governors" element={<Governors />} />
|
<Route path="/site/governors" element={<Governors />} />
|
||||||
<Route path="/site/houses" element={<Houses />} />
|
<Route path="/site/houses" element={<Houses />} />
|
||||||
|
<Route path="/site/rules" element={<Rules />} />
|
||||||
|
<Route path="/site/atlas" element={<Atlas />} />
|
||||||
|
<Route path="/site/atlas/:slug" element={<AtlasCreature />} />
|
||||||
|
<Route path="/site/leaderboards" element={<Leaderboards />} />
|
||||||
|
<Route path="/site/market" element={<Market />} />
|
||||||
|
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
||||||
<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
|
||||||
@@ -132,11 +150,14 @@ 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-visibility" element={<ShardVisibility />} />
|
||||||
|
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
|
||||||
<Route
|
<Route
|
||||||
path="shard-ops"
|
path="shard-ops"
|
||||||
element={
|
element={
|
||||||
@@ -166,6 +187,8 @@ 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 path="/invite/:token" element={<AcceptInvite />} />
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
@@ -177,6 +200,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 />} />
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -52,14 +56,46 @@ export const api = {
|
|||||||
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
||||||
acceptInvite: (token, username, password, extra = {}) =>
|
acceptInvite: (token, username, password, extra = {}) =>
|
||||||
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||||
loginTotp: (challenge, code) =>
|
// Second factor for web login. `extra` carries the optional recoveryCode (an
|
||||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
// 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). `extra` carries the trustDevice/
|
||||||
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
// deviceName opt-in, same as the password path. Returns { user, returnTo } — plus
|
||||||
|
// { trustLimitReached, devices } when trust was asked for but the cap is reached.
|
||||||
|
ssoLoginTotp: (code, extra = {}) => req('/auth/sso/totp', { method: 'POST', body: { code, ...extra } }),
|
||||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
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'),
|
||||||
@@ -72,7 +108,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'),
|
||||||
@@ -93,19 +129,93 @@ 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'),
|
champs: () => req('/public/shard/champs'),
|
||||||
// Protocol 2.0 boards.
|
// Protocol 2.0 boards.
|
||||||
guilds: () => req('/public/shard/guilds'),
|
guilds: () => req('/public/shard/guilds'),
|
||||||
governors: () => req('/public/shard/governors'),
|
governors: () => req('/public/shard/governors'),
|
||||||
governorHistory: (city, limit) =>
|
governorHistory: (city, limit) => {
|
||||||
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`),
|
const q = limit ? `limit=${limit}` : ''
|
||||||
|
return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
|
||||||
|
},
|
||||||
presence: () => req('/public/shard/presence'),
|
presence: () => req('/public/shard/presence'),
|
||||||
houses: () => req('/public/shard/houses'),
|
houses: () => req('/public/shard/houses'),
|
||||||
|
// Protocol 3.0: the shard's published ruleset. Resolves to null when the
|
||||||
|
// shard has never published one — a real answer, not an error.
|
||||||
|
ruleset: () => req('/public/shard/ruleset'),
|
||||||
|
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
||||||
|
// `board` 404s for a system the shard has never published.
|
||||||
|
points: () => req('/public/shard/points'),
|
||||||
|
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
|
||||||
|
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
|
||||||
|
// the page debounces its search box rather than firing per keystroke.
|
||||||
|
market: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
|
||||||
|
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
|
||||||
|
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
|
||||||
|
if (opts.map) qs.set('map', opts.map)
|
||||||
|
if (opts.region) qs.set('region', opts.region)
|
||||||
|
if (opts.sort) qs.set('sort', opts.sort)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
if (opts.offset) qs.set('offset', opts.offset)
|
||||||
|
return req(`/public/shard/market${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
marketMeta: () => req('/public/shard/market/meta'),
|
||||||
|
marketVendor: (serial, opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
if (opts.offset) qs.set('offset', opts.offset)
|
||||||
|
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
// Which shard surfaces this caller may reach, plus the audience rung they
|
||||||
|
// resolved to. Drives nav so we never render a link that would 403.
|
||||||
|
features: () => req('/public/shard/features'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----- spawn atlas (Protocol 3.0 Part C) -----
|
||||||
|
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
|
||||||
|
// not under /shard, because nothing here depends on the sidecar and the pages
|
||||||
|
// stay populated while the shard is offline.
|
||||||
|
atlas: {
|
||||||
|
creatures: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
if (opts.offset) qs.set('offset', opts.offset)
|
||||||
|
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
creature: (slug, opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.points) qs.set('points', opts.points)
|
||||||
|
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
regions: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
return req(`/public/atlas/regions${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
landmarks: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
// The CONFIGURED altar roster, not the live board — see shard.champs() for
|
||||||
|
// "which spawn is on level 3 right now".
|
||||||
|
champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
|
||||||
|
meta: () => req('/public/atlas/meta'),
|
||||||
},
|
},
|
||||||
// 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
|
||||||
@@ -117,7 +227,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 }),
|
||||||
@@ -175,6 +288,13 @@ export const api = {
|
|||||||
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.
|
// Email invites.
|
||||||
listInvites: () => req('/admin/invites'),
|
listInvites: () => req('/admin/invites'),
|
||||||
createInvite: (email, role, sendEmail = true) =>
|
createInvite: (email, role, sendEmail = true) =>
|
||||||
@@ -204,7 +324,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 = {}) => {
|
||||||
@@ -213,21 +333,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 = {}) => {
|
||||||
@@ -236,12 +356,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' }),
|
||||||
@@ -280,6 +415,25 @@ export const api = {
|
|||||||
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||||
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' }),
|
||||||
|
// Per-feature shard visibility: who may see which shard surface, and which
|
||||||
|
// sensitive fields within it. Admin only — it decides what ANONYMOUS
|
||||||
|
// visitors get. acct/webId are admin-only always and the API rejects any
|
||||||
|
// attempt to configure them.
|
||||||
|
getShardVisibility: () => req('/admin/shard/visibility'),
|
||||||
|
saveShardVisibility: (features) =>
|
||||||
|
req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
|
||||||
|
|
||||||
|
// ----- spawn atlas operation (admin only) -----
|
||||||
|
// The atlas re-derives itself from the ServUO tree on every boot; these are
|
||||||
|
// for applying a map change without a restart, and for the approve/reject
|
||||||
|
// decision on a refresh that would remove a facet.
|
||||||
|
atlas: {
|
||||||
|
status: () => req('/admin/shard/atlas'),
|
||||||
|
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
|
||||||
|
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
|
||||||
|
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
|
||||||
|
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
||||||
|
},
|
||||||
|
|
||||||
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
||||||
// `actor` is stamped server-side from the session — never sent from here.
|
// `actor` is stamped server-side from the session — never sent from here.
|
||||||
@@ -330,6 +484,12 @@ export const api = {
|
|||||||
createAccount: (account, password) =>
|
createAccount: (account, password) =>
|
||||||
req('/player/shard/account', { method: 'POST', body: { 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' }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,24 +10,89 @@ 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' }
|
||||||
|
|
||||||
|
// What to call an equipped item.
|
||||||
|
//
|
||||||
|
// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
|
||||||
|
// to show nothing but the layer and `id 12345`. The server now resolves the
|
||||||
|
// cliloc against its own table and attaches `clilocName` (see
|
||||||
|
// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
|
||||||
|
// and the layer fallback below is exactly what the sheet did before.
|
||||||
|
//
|
||||||
|
// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
|
||||||
|
// should not be relabelled "hatchet" — and the server applies the same
|
||||||
|
// precedence, so this only re-states it for a profile that arrived with both.
|
||||||
|
const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
|
||||||
|
|
||||||
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
// 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
|
// 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
|
// literal string.
|
||||||
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
//
|
||||||
// de-duped list of human-readable title chips.
|
// `rewardResolved` is the server's parallel array with the numeric entries turned
|
||||||
|
// into words (null where the cliloc table had nothing, or is not configured at
|
||||||
|
// all). Prefer it, and keep the literal-only path as the fallback for a profile
|
||||||
|
// served before the cliloc table existed — a numeric entry with no resolution is
|
||||||
|
// still skipped rather than shown as a raw number.
|
||||||
function displayTitles(titles) {
|
function displayTitles(titles) {
|
||||||
if (!titles) return []
|
if (!titles) return []
|
||||||
const out = []
|
const out = []
|
||||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||||
if (titles.skill) out.push(titles.skill)
|
if (titles.skill) out.push(titles.skill)
|
||||||
const reward = Array.isArray(titles.reward) ? titles.reward : []
|
const raw = Array.isArray(titles.reward) ? titles.reward : []
|
||||||
|
const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
|
||||||
|
const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
|
||||||
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||||
// Prefer the selected reward title; fall back to the first literal one.
|
// Prefer the selected reward title; fall back to the first one that resolved.
|
||||||
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
// The `??` matters: a selected title whose cliloc did not resolve must fall
|
||||||
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
// through to the fallback rather than suppress the chip entirely.
|
||||||
|
const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
|
||||||
|
if (candidate) out.push(String(candidate))
|
||||||
return [...new Set(out.filter(Boolean))]
|
return [...new Set(out.filter(Boolean))]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
|
||||||
|
// the character actually holds a score in. Systems at zero are omitted by the
|
||||||
|
// shard, so an empty list means "this character has earned nothing anywhere",
|
||||||
|
// which is a normal state for a new character and renders as nothing at all.
|
||||||
|
//
|
||||||
|
// `nameString` may be null when the system's name is a cliloc; fall back to
|
||||||
|
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
|
||||||
|
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
|
||||||
|
// "unranked" are different, so the chip only appears when it was actually sent.
|
||||||
|
const humanisePoints = (key) =>
|
||||||
|
String(key || '')
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/^./, (c) => c.toUpperCase())
|
||||||
|
|
||||||
|
function PointsRow({ entry }) {
|
||||||
|
const label = entry.nameString || humanisePoints(entry.system)
|
||||||
|
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
|
||||||
|
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
|
||||||
|
{label}
|
||||||
|
{Number.isFinite(entry.rank) && (
|
||||||
|
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||||
|
{(entry.points ?? 0).toLocaleString()}
|
||||||
|
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
|
||||||
|
be a fraction of, and a full-width bar would imply completion. */}
|
||||||
|
{max > 0 && (
|
||||||
|
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function TitleChip({ children, tone = 'var(--muted)' }) {
|
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -75,6 +140,11 @@ export default function CharacterSheet({ char, moderation = false }) {
|
|||||||
.filter((s) => (s.value || s.base || 0) > 0)
|
.filter((s) => (s.value || s.base || 0) > 0)
|
||||||
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||||
const equipment = char.equipment || []
|
const equipment = char.equipment || []
|
||||||
|
// Best standing first, so the character's strongest loyalty leads. Guarded for
|
||||||
|
// an older shard plugin that sends no `points` block at all.
|
||||||
|
const points = (Array.isArray(char.points) ? char.points : [])
|
||||||
|
.filter((p) => p && (p.points || 0) > 0)
|
||||||
|
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
@@ -173,17 +243,37 @@ export default function CharacterSheet({ char, moderation = false }) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Loyalty & points — one entry per system this character has scored in */}
|
||||||
|
{points.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||||
|
Loyalty & points <span className="dim">({points.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||||
|
{points.map((p) => (
|
||||||
|
<PointsRow key={p.system} entry={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Equipment */}
|
{/* Equipment */}
|
||||||
{equipment.length > 0 && (
|
{equipment.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{equipment.map((it) => (
|
{equipment.map((it) => {
|
||||||
|
const label = itemName(it)
|
||||||
|
const layer = it.layer || 'Item'
|
||||||
|
// The layer only earns its own line once the headline is a real
|
||||||
|
// name; when it IS the headline, repeating it is just noise.
|
||||||
|
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
|
||||||
|
return (
|
||||||
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
|
||||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
|
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
|
||||||
</div>
|
</div>
|
||||||
{it.mods && Object.keys(it.mods).length > 0 && (
|
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||||
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||||
@@ -193,7 +283,8 @@ export default function CharacterSheet({ char, moderation = false }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ function UnlinkButton({ account, onUnlink }) {
|
|||||||
try {
|
try {
|
||||||
await onUnlink(account)
|
await onUnlink(account)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.'))
|
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
|
||||||
|
setError(byStatus[err.status] || err.message || 'Could not unlink.')
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ export default function ShardAccountActions({ account, style }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kick = () =>
|
const kick = () =>
|
||||||
run('kick', () => api.admin.shardOps.kick({ account }), (r) =>
|
run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
|
||||||
`Kicked${r && r.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`,
|
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 unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
|
||||||
const ban = () =>
|
const ban = () =>
|
||||||
run('ban', () =>
|
run('ban', () =>
|
||||||
@@ -49,7 +52,8 @@ export default function ShardAccountActions({ account, style }) {
|
|||||||
}),
|
}),
|
||||||
() => {
|
() => {
|
||||||
setBanOpen(false)
|
setBanOpen(false)
|
||||||
return `Banned${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`
|
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
||||||
|
return `Banned${when}.`
|
||||||
})
|
})
|
||||||
|
|
||||||
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
|
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function SiteFooter() {
|
|||||||
{contactEmail}
|
{contactEmail}
|
||||||
</a>
|
</a>
|
||||||
·
|
·
|
||||||
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
Shard Status
|
Shard Status
|
||||||
</Link>
|
</Link>
|
||||||
·
|
·
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ 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'
|
import { useSite } from '../contexts/SiteContext.jsx'
|
||||||
|
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
||||||
|
|
||||||
// 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).
|
||||||
|
//
|
||||||
|
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
|
||||||
|
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
|
||||||
|
// viewer can't reach them, so we never render a link that would 403. The gate
|
||||||
|
// itself is server-side; this is only about not advertising a dead end.
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ label: 'Home', to: '/', end: true },
|
{ label: 'Home', to: '/', end: true },
|
||||||
{ label: 'News', to: '/site/news' },
|
{ label: 'News', to: '/site/news' },
|
||||||
@@ -12,11 +18,15 @@ const NAV = [
|
|||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
{ 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', feature: 'status' },
|
||||||
{ label: 'Champions', to: '/site/champs' },
|
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||||
{ label: 'Guilds', to: '/site/guilds' },
|
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||||
{ label: 'Governors', to: '/site/governors' },
|
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||||
{ label: 'Houses', to: '/site/houses' },
|
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||||
|
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
||||||
|
{ label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
||||||
|
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
||||||
|
{ label: 'Market', to: '/site/market', feature: 'market' },
|
||||||
{ label: 'About', to: '/site/about' },
|
{ label: 'About', to: '/site/about' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -29,14 +39,14 @@ const linkStyle = ({ isActive }) => ({
|
|||||||
export default function SiteHeader() {
|
export default function SiteHeader() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const { siteTitle } = useSite()
|
const { siteTitle } = useSite()
|
||||||
|
const shardFeatures = useShardFeatures()
|
||||||
|
const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||||
|
|
||||||
// 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
|
||||||
@@ -62,7 +72,7 @@ export default function SiteHeader() {
|
|||||||
{siteTitle}
|
{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) => (
|
||||||
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||||
{l.label}
|
{l.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
62
client/src/components/security/RecoveryCodesDisplay.jsx
Normal file
62
client/src/components/security/RecoveryCodesDisplay.jsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// Renders a freshly generated batch of recovery codes ONCE, with copy + download.
|
||||||
|
// The backend never returns these again, so the copy stresses saving them now.
|
||||||
|
export default function RecoveryCodesDisplay({ codes, onDone }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const text = (codes || []).join('\n')
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
/* clipboard blocked — the codes are visible to copy manually */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
const blob = new Blob([`${text}\n`], { type: 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'recovery-codes.txt'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 18, marginTop: 8 }}>
|
||||||
|
<p className="sans" style={{ margin: '0 0 12px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Save these recovery codes somewhere safe. Each can be used <strong>once</strong> to sign in if you
|
||||||
|
lose your authenticator. <strong>They will not be shown again.</strong>
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(codes || []).map((c) => (
|
||||||
|
<div key={c} style={{ padding: '8px 10px', border: '1px solid var(--line-soft)', borderRadius: 6, letterSpacing: '0.06em', textAlign: 'center', color: 'var(--head)' }}>
|
||||||
|
{c}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={copy} className="pill">{copied ? 'Copied!' : 'Copy'}</button>
|
||||||
|
<button onClick={download} className="pill">Download</button>
|
||||||
|
{onDone && (
|
||||||
|
<button onClick={onDone} className="btn btn-primary btn-sq" style={{ marginLeft: 'auto' }}>
|
||||||
|
I’ve saved them
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
client/src/components/security/RecoveryCodesPanel.jsx
Normal file
86
client/src/components/security/RecoveryCodesPanel.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import RecoveryCodesDisplay from './RecoveryCodesDisplay.jsx'
|
||||||
|
|
||||||
|
// Self-service recovery (backup) codes. Shows how many remain and lets the user
|
||||||
|
// regenerate a fresh set (password step-up). Shown only when 2FA is enabled.
|
||||||
|
// `hasPassword` decides whether the current-password field is required — an
|
||||||
|
// SSO-only account with no password may regenerate while authenticated.
|
||||||
|
export default function RecoveryCodesPanel({ hasPassword = true }) {
|
||||||
|
const [remaining, setRemaining] = useState(null)
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [codes, setCodes] = useState(null) // freshly generated batch, shown once
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { remaining: n } = await api.recoveryCodesStatus()
|
||||||
|
setRemaining(n)
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — the panel still offers regeneration */
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function regenerate() {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const { recoveryCodes } = await api.generateRecoveryCodes(hasPassword ? currentPassword : undefined)
|
||||||
|
setCodes(recoveryCodes)
|
||||||
|
setCurrentPassword('')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not generate recovery codes.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||||
|
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||||
|
Recovery codes
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||||
|
Single-use codes that let you sign in if you lose your authenticator. Regenerating replaces any
|
||||||
|
codes you still have.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{remaining != null && !codes && (
|
||||||
|
<p className="sans" style={{ color: remaining > 0 ? '#7fd0a4' : '#e0b352', fontSize: '0.86rem' }}>
|
||||||
|
{remaining > 0 ? `${remaining} unused code${remaining === 1 ? '' : 's'} remaining.` : 'No unused recovery codes left — regenerate a set.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{codes ? (
|
||||||
|
<RecoveryCodesDisplay codes={codes} onDone={() => setCodes(null)} />
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 10 }}>
|
||||||
|
{hasPassword && (
|
||||||
|
<label style={{ display: 'block', maxWidth: 260 }}>
|
||||||
|
<span className="field-label">Current password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button onClick={regenerate} disabled={busy || (hasPassword && !currentPassword)} className="btn btn-sq">
|
||||||
|
{busy ? 'Generating…' : 'Generate new codes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
client/src/components/security/TrustLimitModal.jsx
Normal file
124
client/src/components/security/TrustLimitModal.jsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// Shown when a user tries to trust a device but is already at the trusted-device
|
||||||
|
// cap. Styled like the TOTP entry flow (centered card on a dim overlay). The user
|
||||||
|
// MUST revoke at least one existing device before they can continue — there is no
|
||||||
|
// silent pruning — or they can cancel and leave the device untrusted.
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// devices — the existing trusted devices (from the 409 / trustLimitReached payload)
|
||||||
|
// onTrusted — called after the current device is successfully trusted (post-revoke)
|
||||||
|
// onCancel — called when the user backs out without trusting this device
|
||||||
|
export default function TrustLimitModal({ devices: initialDevices, onTrusted, onCancel }) {
|
||||||
|
const [devices, setDevices] = useState(initialDevices || [])
|
||||||
|
const [revokedAny, setRevokedAny] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function revoke(id) {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.revokeTrustedDevice(id)
|
||||||
|
setDevices((list) => list.filter((d) => d.id !== id))
|
||||||
|
setRevokedAny(true)
|
||||||
|
} catch {
|
||||||
|
setError('Could not revoke that device. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trustNow() {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.trustThisDevice()
|
||||||
|
onTrusted?.()
|
||||||
|
} catch (err) {
|
||||||
|
// Still at the cap somehow (a race) — surface it and let them revoke more.
|
||||||
|
if (err.status === 409 && err.body?.devices) {
|
||||||
|
setDevices(err.body.devices)
|
||||||
|
setError('Still at the limit — revoke another device.')
|
||||||
|
} else {
|
||||||
|
setError('Could not trust this device. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={overlay} role="dialog" aria-modal="true" aria-label="Trusted-device limit reached">
|
||||||
|
<div style={card}>
|
||||||
|
<h2 className="display" style={{ margin: '0 0 8px', fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||||
|
Trusted-device limit reached
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ margin: '0 0 16px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
You can trust up to {Math.max(devices.length, 1)} devices. Revoke one below to make room, then
|
||||||
|
continue — or cancel to leave this device untrusted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16, maxHeight: 240, overflowY: 'auto' }}>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<div key={d.id} style={row}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||||
|
{d.deviceName || d.platform || 'Device'}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{d.userAgent || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{devices.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>All devices revoked. You can trust this one now.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.84rem' }}>{error}</p>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={trustNow} disabled={busy || !revokedAny} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Working…' : 'Trust this device'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} disabled={busy} className="pill">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
zIndex: 1000,
|
||||||
|
}
|
||||||
|
const card = {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 460,
|
||||||
|
background: 'var(--panel, #1a1a1f)',
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 24,
|
||||||
|
}
|
||||||
|
const row = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 8,
|
||||||
|
}
|
||||||
137
client/src/components/security/TrustedDevicesPanel.jsx
Normal file
137
client/src/components/security/TrustedDevicesPanel.jsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import TrustLimitModal from './TrustLimitModal.jsx'
|
||||||
|
|
||||||
|
// Self-service list of the devices allowed to skip the TOTP step at login (MFA
|
||||||
|
// "Trust this device"). Uses the role-agnostic /auth/me/trusted-devices surface, so
|
||||||
|
// the same panel serves players and staff. Shown only when 2FA is enabled — trust
|
||||||
|
// is meaningless without a second factor to skip.
|
||||||
|
function fmtDate(s) {
|
||||||
|
if (!s) return '—'
|
||||||
|
const d = new Date(s)
|
||||||
|
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrustedDevicesPanel() {
|
||||||
|
const [devices, setDevices] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [capModal, setCapModal] = useState(null) // { devices } when the cap is hit
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setDevices(await api.myTrustedDevices())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your trusted devices.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function trustThis() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.trustThisDevice()
|
||||||
|
setMsg('This device is now trusted.')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409 && err.body?.error === 'trusted_device_limit') {
|
||||||
|
setCapModal({ devices: err.body.devices || [] })
|
||||||
|
} else {
|
||||||
|
setError('Could not trust this device.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revoke(id) {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.revokeTrustedDevice(id)
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
setError('Could not revoke that device.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeAll() {
|
||||||
|
if (!window.confirm('Untrust every device? Each will require the full two-factor step at the next login.')) return
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.revokeAllTrustedDevices()
|
||||||
|
setMsg('All devices untrusted.')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
setError('Could not untrust devices.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!devices) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||||
|
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||||
|
Trusted devices
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||||
|
Devices you’ve trusted skip the authenticator step at login (your password is still required).
|
||||||
|
Revoke any you don’t recognize.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{devices.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||||
|
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{d.userAgent || '—'} · last used {fmtDate(d.lastUsedAt)} · expires {fmtDate(d.expiresAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>No trusted devices yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={trustThis} disabled={busy} className="btn btn-sq">Trust this device</button>
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||||
|
Untrust all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||||
|
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||||
|
|
||||||
|
{capModal && (
|
||||||
|
<TrustLimitModal
|
||||||
|
devices={capModal.devices}
|
||||||
|
onTrusted={() => { setCapModal(null); setMsg('This device is now trusted.'); load() }}
|
||||||
|
onCancel={() => setCapModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import { api } from '../api/client.js'
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
const AuthContext = createContext(null)
|
||||||
@@ -38,17 +38,22 @@ 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
|
||||||
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
|
// an httpOnly cookie, so only the code is sent. `extra` carries the trustDevice/
|
||||||
const ssoLoginTotp = useCallback(async (code) => {
|
// deviceName opt-in. Returns the full payload ({ user, returnTo,
|
||||||
const data = await api.ssoLoginTotp(code)
|
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
|
||||||
|
const ssoLoginTotp = useCallback(async (code, extra) => {
|
||||||
|
const data = await api.ssoLoginTotp(code, extra)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
return data
|
return data
|
||||||
}, [])
|
}, [])
|
||||||
@@ -61,8 +66,15 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Memoized so consumers don't re-render on every provider render (the callbacks
|
||||||
|
// are already stable via useCallback).
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }),
|
||||||
|
[user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}>
|
<AuthContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,7 +23,7 @@ export function SiteProvider({ children }) {
|
|||||||
refresh()
|
refresh()
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const brand = settings.brand || {}
|
const brand = useMemo(() => settings.brand || {}, [settings])
|
||||||
|
|
||||||
// Apply the instance accent color to the CSS variable the theme is built on,
|
// Apply the instance accent color to the CSS variable the theme is built on,
|
||||||
// so branding flows to every `var(--accent)` at runtime (no rebuild).
|
// so branding flows to every `var(--accent)` at runtime (no rebuild).
|
||||||
@@ -31,7 +31,10 @@ export function SiteProvider({ children }) {
|
|||||||
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
|
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
|
||||||
}, [brand.accent])
|
}, [brand.accent])
|
||||||
|
|
||||||
const value = {
|
// 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,
|
settings,
|
||||||
loading,
|
loading,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -41,7 +44,9 @@ export function SiteProvider({ children }) {
|
|||||||
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
|
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
|
||||||
contactEmail: brand.contactEmail || settings.contact_email || '',
|
contactEmail: brand.contactEmail || settings.contact_email || '',
|
||||||
heroImage: brand.hero || '/assets/img/runic-emblem.png',
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,16 @@
|
|||||||
// membership) and the widget follows. Anything not matched lands in "Wilderness"
|
// membership) and the widget follows. Anything not matched lands in "Wilderness"
|
||||||
// so the bucket counts always reconcile to the true total.
|
// 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
|
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
|
||||||
// membership. First matching bucket wins; the last bucket is the catch-all.
|
// membership. First matching bucket wins; the last bucket is the catch-all.
|
||||||
export const BUCKETS = [
|
export const BUCKETS = [
|
||||||
@@ -17,10 +27,10 @@ export const BUCKETS = [
|
|||||||
id: 'towns',
|
id: 'towns',
|
||||||
label: 'Towns',
|
label: 'Towns',
|
||||||
// The other named cities/towns.
|
// The other named cities/towns.
|
||||||
match: (r) =>
|
match: (r) => {
|
||||||
/^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test(
|
const norm = normalizeRegion(r)
|
||||||
r,
|
return TOWN_PREFIXES.some((t) => norm.startsWith(t))
|
||||||
),
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dungeons',
|
id: 'dungeons',
|
||||||
|
|||||||
@@ -10,73 +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}` : ''}`
|
|
||||||
case 'champ.update': {
|
|
||||||
const where = p.name || p.type || 'A champion spawn'
|
|
||||||
if (p.status === 'active' && p.bossUp) return `${where}: boss is up${p.boss ? ` (${p.boss})` : ''}`
|
|
||||||
if (p.status === 'active') return `${where} is active${p.level != null ? ` — level ${p.level}` : ''}`
|
|
||||||
if (p.status === 'cooldown') return `${where} is on cooldown`
|
|
||||||
return `${where} is ${p.status || 'idle'}`
|
|
||||||
}
|
|
||||||
case 'champ.remove':
|
|
||||||
return `A champion spawn ended`
|
|
||||||
// Support (help-page) queue + in-game moderation (admin channel only)
|
|
||||||
case 'page.new':
|
|
||||||
return `New ${p.type || 'help'} page from ${nameOf(p.sender)}`
|
|
||||||
case 'page.updated':
|
|
||||||
return `Help page from ${nameOf(p.sender)} updated${p.handled ? ' (claimed)' : ''}`
|
|
||||||
case 'page.closed':
|
|
||||||
return `Help page ${p.pageId || ''} closed`
|
|
||||||
case 'admin.audit':
|
|
||||||
return `${p.actor || 'Staff'} ${p.action || 'acted'}${p.target ? ` on ${p.target}` : ''}${p.origin ? ` [${p.origin}]` : ''}`
|
|
||||||
// Staff / sensitive (admin channel only)
|
|
||||||
case 'audit.set':
|
|
||||||
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`
|
|
||||||
case 'audit.command':
|
|
||||||
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}`
|
|
||||||
case 'cheat.fastwalk':
|
|
||||||
return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}`
|
|
||||||
case 'account.login.attempt':
|
|
||||||
return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}`
|
|
||||||
case 'gold.change':
|
|
||||||
return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}`
|
|
||||||
default:
|
|
||||||
return ev.kind
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category grouping for the filter tabs.
|
// Category grouping for the filter tabs.
|
||||||
|
|||||||
58
client/src/lib/useShardFeatures.js
Normal file
58
client/src/lib/useShardFeatures.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Which shard surfaces the current viewer may reach, from
|
||||||
|
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
|
||||||
|
// Visibility), so the nav can't be a static list any more.
|
||||||
|
//
|
||||||
|
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
|
||||||
|
// and an out-of-rung one 403s whether or not the link is rendered. So while the
|
||||||
|
// answer is still in flight we return `null` and callers show their default set
|
||||||
|
// — better a link that briefly 403s than a nav that flickers in on every load.
|
||||||
|
//
|
||||||
|
// Cached module-level: the answer is per-viewer but stable for a session, and
|
||||||
|
// every consumer would otherwise refetch it on mount.
|
||||||
|
let cached = null
|
||||||
|
let inFlight = null
|
||||||
|
|
||||||
|
export function resetShardFeatures() {
|
||||||
|
cached = null
|
||||||
|
inFlight = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShardFeatures() {
|
||||||
|
const [features, setFeatures] = useState(cached)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cached) return undefined
|
||||||
|
let alive = true
|
||||||
|
inFlight =
|
||||||
|
inFlight ||
|
||||||
|
api.shard
|
||||||
|
.features()
|
||||||
|
.then((data) => {
|
||||||
|
cached = { level: data.level, set: new Set(data.features || []) }
|
||||||
|
return cached
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// A failed lookup must not blank the nav — fall back to "show
|
||||||
|
// everything" and let the server do the gating.
|
||||||
|
cached = null
|
||||||
|
inFlight = null
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
inFlight.then((result) => {
|
||||||
|
if (alive) setFeatures(result)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return features
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: true when `name` is visible, or when we don't know yet.
|
||||||
|
export function canSee(features, name) {
|
||||||
|
return !features || features.set.has(name)
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ 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/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||||
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
|
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||||
],
|
],
|
||||||
@@ -77,6 +78,8 @@ const NAV = [
|
|||||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||||
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
||||||
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
||||||
|
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
|
||||||
|
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
|
||||||
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -97,6 +100,7 @@ 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/shard-ops': 'In-Game Ops',
|
||||||
'/admin/houses': 'House Registry',
|
'/admin/houses': 'House Registry',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
@@ -104,6 +108,8 @@ const TITLES = {
|
|||||||
'/admin/bot-activity': 'Web Bot Activity',
|
'/admin/bot-activity': 'Web Bot Activity',
|
||||||
'/admin/discord-bot': 'Discord Bot',
|
'/admin/discord-bot': 'Discord Bot',
|
||||||
'/admin/shard': 'Shard (uo-link)',
|
'/admin/shard': 'Shard (uo-link)',
|
||||||
|
'/admin/shard-visibility': 'Shard Visibility',
|
||||||
|
'/admin/shard-atlas': 'Spawn Atlas',
|
||||||
'/admin/characters': 'My Characters',
|
'/admin/characters': 'My Characters',
|
||||||
'/admin/auth-providers': 'Authentication',
|
'/admin/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
@@ -111,6 +117,14 @@ const TITLES = {
|
|||||||
'/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,
|
||||||
@@ -129,15 +143,7 @@ export default function AdminLayout() {
|
|||||||
const { mode, siteTitle } = 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'
|
|
||||||
: location.pathname.startsWith('/admin/users/')
|
|
||||||
? 'User'
|
|
||||||
: '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)'
|
||||||
@@ -145,7 +151,7 @@ export default function AdminLayout() {
|
|||||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||||
// own account security.
|
// own account security.
|
||||||
const isModerator = user?.role === 'moderator'
|
const isModerator = user?.role === 'moderator'
|
||||||
const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
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 MOD_PATHS.includes(item.to)
|
if (isModerator) return MOD_PATHS.includes(item.to)
|
||||||
@@ -234,7 +240,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}
|
||||||
@@ -256,7 +262,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
@@ -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=...).
|
||||||
@@ -119,19 +123,34 @@ export default function AdminLogin() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (ssoTotp) {
|
if (ssoTotp) {
|
||||||
const { returnTo } = await ssoLoginTotp(code)
|
// Trust works on the SSO second factor exactly as it does on the password
|
||||||
navigate(returnTo || '/admin', { replace: true })
|
// one — the IdP already proved the first factor.
|
||||||
|
const data = await ssoLoginTotp(code.trim(), { trustDevice })
|
||||||
|
const to = data.returnTo || '/admin'
|
||||||
|
if (data.trustLimitReached) {
|
||||||
|
setTrustLimit({ devices: data.devices || [], dest: to })
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(to, { replace: true })
|
||||||
} else {
|
} 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 +159,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={{
|
||||||
@@ -219,22 +242,42 @@ 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 }}>
|
||||||
|
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode={useRecovery ? 'text' : 'numeric'}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="6-digit code"
|
placeholder={useRecovery ? 'xxxxx-xxxxx' : '6-digit code'}
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
className="input"
|
className="input"
|
||||||
/>
|
/>
|
||||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||||
Enter the code from your authenticator app.
|
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{/* Offered on the SSO second factor too — the trust is on the device,
|
||||||
|
not on how the first factor was proved. */}
|
||||||
|
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
|
||||||
|
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||||
|
Trust this device for 30 days (skip the code next time)
|
||||||
|
</label>
|
||||||
|
{/* Recovery codes remain password-login only: the SSO second step
|
||||||
|
verifies an authenticator code against the staged challenge. */}
|
||||||
|
{!ssoTotp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(error || (stage === 'creds' && ssoError)) && (
|
{(error || (stage === 'creds' && ssoError)) && (
|
||||||
@@ -249,7 +292,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. */}
|
||||||
@@ -300,6 +343,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
288
client/src/routes/admin/views/Appeals.jsx
Normal file
288
client/src/routes/admin/views/Appeals.jsx
Normal 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)' }
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import { useAsync } from '../../../lib/useAsync.js'
|
|||||||
import { ago, dateTime } from '../../../lib/format.js'
|
import { ago, dateTime } from '../../../lib/format.js'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { refresh: refreshSite } = useSite()
|
const { refresh: refreshSite } = useSite()
|
||||||
|
const { user } = useAuth()
|
||||||
|
// PUT /admin/site-mode is adminOnly. The dashboard itself is staff-wide, so the
|
||||||
|
// toggle needs its own gate — same rule the sidebar follows (AdminLayout: never
|
||||||
|
// show a non-admin a control that would 403).
|
||||||
|
const isAdmin = user?.role === 'admin'
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
|
|
||||||
@@ -15,6 +21,7 @@ export default function Dashboard() {
|
|||||||
[tick],
|
[tick],
|
||||||
)
|
)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [modeError, setModeError] = useState('')
|
||||||
|
|
||||||
if (loading) return <Loading />
|
if (loading) return <Loading />
|
||||||
if (error) return <ErrorState message="Could not load the dashboard." />
|
if (error) return <ErrorState message="Could not load the dashboard." />
|
||||||
@@ -32,12 +39,21 @@ export default function Dashboard() {
|
|||||||
{ value: dash.counts?.users ?? 0, label: 'Users' },
|
{ value: dash.counts?.users ?? 0, label: 'Users' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// The rejection was previously unhandled: a refused toggle surfaced only as an
|
||||||
|
// unhandled promise rejection in the console while the button silently reverted.
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
setModeError('')
|
||||||
try {
|
try {
|
||||||
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
|
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
|
||||||
await refreshSite()
|
await refreshSite()
|
||||||
reload()
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setModeError(
|
||||||
|
err.status === 403
|
||||||
|
? 'Only an administrator can change the site mode.'
|
||||||
|
: 'Could not change the site mode. Try again.',
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -45,6 +61,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
|
||||||
@@ -75,15 +94,22 @@ export default function Dashboard() {
|
|||||||
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
|
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
|
||||||
{changed.at ? ` · ${dateTime(changed.at)}` : ''}
|
{changed.at ? ` · ${dateTime(changed.at)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
{modeError && (
|
||||||
|
<div className="sans" style={{ fontSize: '0.8rem', marginTop: 8, color: 'var(--danger, #d98b8b)' }}>
|
||||||
|
{modeError}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
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>
|
||||||
|
|
||||||
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>
|
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>
|
||||||
|
|||||||
@@ -61,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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function CreateInvite({ onCreated }) {
|
|||||||
const [sendEmail, setSendEmail] = useState(true)
|
const [sendEmail, setSendEmail] = useState(true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [result, setResult] = useState(null) // { emailed, acceptUrl, emailError }
|
const [result, setResult] = useState(null) // fields emailed, acceptUrl, emailError
|
||||||
|
|
||||||
async function submit(e) {
|
async function submit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -62,6 +62,16 @@ function CreateInvite({ onCreated }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
|
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
|
||||||
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
|
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
|
||||||
@@ -77,7 +87,7 @@ function CreateInvite({ onCreated }) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||||
{busy ? 'Creating…' : (sendEmail ? 'Create & email' : 'Create link')}
|
{busy ? 'Creating…' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -90,9 +100,7 @@ function CreateInvite({ onCreated }) {
|
|||||||
{result && (
|
{result && (
|
||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
|
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
{result.emailed
|
{resultText}
|
||||||
? 'Invitation emailed. You can also share this single-use link:'
|
|
||||||
: `Invite created${result.emailError ? ` (email not sent: ${result.emailError})` : ''}. Share this single-use link:`}
|
|
||||||
</p>
|
</p>
|
||||||
<CopyLink url={result.acceptUrl} />
|
<CopyLink url={result.acceptUrl} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 user’s history." />
|
if (error) return <ErrorState message="Could not load this user’s 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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -109,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}>
|
||||||
@@ -124,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}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function ShardAdmin() {
|
|||||||
const [baseUrl, setBaseUrl] = useState('')
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
const [wsUrl, setWsUrl] = useState('')
|
const [wsUrl, setWsUrl] = useState('')
|
||||||
const [token, setToken] = useState('')
|
const [token, setToken] = useState('')
|
||||||
const [protocol, setProtocol] = useState(1)
|
const [protocol, setProtocol] = useState(3)
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
@@ -172,7 +172,7 @@ export default function ShardAdmin() {
|
|||||||
if (!initializedRef.current) {
|
if (!initializedRef.current) {
|
||||||
setBaseUrl(c.baseUrl || '')
|
setBaseUrl(c.baseUrl || '')
|
||||||
setWsUrl(c.wsUrl || '')
|
setWsUrl(c.wsUrl || '')
|
||||||
setProtocol(c.protocol || 1)
|
setProtocol(c.protocol || 3)
|
||||||
setEnabled(c.enabled)
|
setEnabled(c.enabled)
|
||||||
initializedRef.current = true
|
initializedRef.current = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,9 +91,26 @@ function AccountActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kick = () =>
|
const kick = () =>
|
||||||
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => `Kicked ${acct}${r?.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`)
|
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 = () =>
|
const ban = () =>
|
||||||
run('ban', () => api.admin.shardOps.ban({ account: acct, durationSec: durationSec === '' ? undefined : Number(durationSec), reason: reason.trim() || undefined }), () => `Banned ${acct}${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`)
|
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}.`)
|
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -200,6 +217,19 @@ function SupportQueue() {
|
|||||||
return () => clearInterval(pollRef.current)
|
return () => clearInterval(pollRef.current)
|
||||||
}, [load])
|
}, [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 (
|
return (
|
||||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<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>
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
|
||||||
@@ -207,15 +237,7 @@ function SupportQueue() {
|
|||||||
Open help pages from players. A reply reaches them in game (or on their next login).
|
Open help pages from players. A reply reaches them in game (or on their next login).
|
||||||
</p>
|
</p>
|
||||||
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
|
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
|
||||||
{pages == null ? (
|
{queueBody}
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
|
||||||
) : pages.length === 0 ? (
|
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
||||||
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
325
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
325
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// ── Admin · Shard visibility ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Who may see which shard surface, and which sensitive fields within it.
|
||||||
|
// Admin-only, because this decides what ANONYMOUS visitors get.
|
||||||
|
//
|
||||||
|
// Two things the UI must communicate honestly, because they are not negotiable
|
||||||
|
// server-side (see docs/link/v3.md §3.4):
|
||||||
|
// • acct / webId are admin-only always and are not listed as editable fields.
|
||||||
|
// • an event kind the server doesn't know about never reaches anyone below
|
||||||
|
// admin, whatever is set here.
|
||||||
|
//
|
||||||
|
// Defaults reproduce the behavior the site had before this panel existed, so a
|
||||||
|
// fresh install shows "everything as it was" rather than an empty form.
|
||||||
|
|
||||||
|
const RUNG_LABEL = {
|
||||||
|
anonymous: 'Everyone',
|
||||||
|
logged_in: 'Signed in',
|
||||||
|
player: 'Linked players',
|
||||||
|
staff: 'Staff',
|
||||||
|
admin: 'Admins only',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUNG_HINT = {
|
||||||
|
anonymous: 'Visible to anyone, signed in or not.',
|
||||||
|
logged_in: 'Any signed-in account, linked or not.',
|
||||||
|
player: 'Accounts with a linked game account. Staff always qualify.',
|
||||||
|
staff: 'Admins and moderators.',
|
||||||
|
admin: 'Admins only.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_LABEL = {
|
||||||
|
status: 'Shard status',
|
||||||
|
activity: 'Activity feed',
|
||||||
|
champs: 'Champion spawns',
|
||||||
|
guilds: 'Guilds',
|
||||||
|
governors: 'Town governors',
|
||||||
|
houses: 'Houses / IDOC',
|
||||||
|
presence: 'Players online',
|
||||||
|
ruleset: 'Shard rules',
|
||||||
|
atlas: 'Spawn atlas',
|
||||||
|
leaderboards: 'Leaderboards',
|
||||||
|
market: 'Marketplace',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_HINT = {
|
||||||
|
status: 'Connection state, online count, gold-supply series.',
|
||||||
|
activity: 'Deaths, kills, skill gains, quests, logins.',
|
||||||
|
champs: 'The live champion / mini-champ / sea-boss board.',
|
||||||
|
guilds: 'Guild rosters, alliances and leaders.',
|
||||||
|
governors: 'City Loyalty governors, elections and term history.',
|
||||||
|
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
|
||||||
|
presence: 'Population aggregate and the staff-online widget.',
|
||||||
|
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
|
||||||
|
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
|
||||||
|
leaderboards: 'Point and loyalty standings across every points system.',
|
||||||
|
market: 'The shard-wide player-vendor index.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABEL = {
|
||||||
|
owner: 'House owner',
|
||||||
|
price: 'House price',
|
||||||
|
location: 'In-game location (map + coordinates)',
|
||||||
|
connect: 'Server connect address',
|
||||||
|
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
|
||||||
|
// projection matches literal JSON keys, so the rule cannot be spelled after the
|
||||||
|
// field's meaning. The label is what carries the meaning to the admin.
|
||||||
|
name: 'Character names on leaderboards',
|
||||||
|
ownerName: 'Vendor owner name',
|
||||||
|
// One rule, one key — `location` is a nested object on both the wire frame and
|
||||||
|
// the stored read model precisely so that hiding it takes the facet, the
|
||||||
|
// coordinates, the region and the house together.
|
||||||
|
ownerSerial: 'Vendor owner character id',
|
||||||
|
}
|
||||||
|
|
||||||
|
function RungSelect({ value, onChange, ladder, disabled }) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
style={{ maxWidth: 200 }}
|
||||||
|
>
|
||||||
|
{ladder.map((rung) => (
|
||||||
|
<option key={rung} value={rung}>
|
||||||
|
{RUNG_LABEL[rung] || rung}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
|
||||||
|
const fields = Object.entries(settings.fields || {})
|
||||||
|
const changed =
|
||||||
|
defaults &&
|
||||||
|
(settings.enabled !== defaults.enabled ||
|
||||||
|
settings.audience !== defaults.audience ||
|
||||||
|
settings.stream !== defaults.stream ||
|
||||||
|
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
opacity: settings.enabled ? 1 : 0.62,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
{FEATURE_LABEL[name] || name}
|
||||||
|
{changed && (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
changed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||||
|
{FEATURE_HINT[name]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
className="sans"
|
||||||
|
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enabled}
|
||||||
|
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Who can see it</span>
|
||||||
|
<RungSelect
|
||||||
|
value={settings.audience}
|
||||||
|
ladder={ladder}
|
||||||
|
disabled={!settings.enabled}
|
||||||
|
onChange={(audience) => onPatch(name, { audience })}
|
||||||
|
/>
|
||||||
|
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
|
||||||
|
{RUNG_HINT[settings.audience]}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.stream}
|
||||||
|
disabled={!settings.enabled}
|
||||||
|
onChange={(e) => onPatch(name, { stream: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Live updates
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length > 0 && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
|
||||||
|
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
Sensitive fields
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||||
|
{fields.map(([field, rung]) => (
|
||||||
|
<label key={field} style={{ display: 'block' }}>
|
||||||
|
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
|
||||||
|
{FIELD_LABEL[field] || field}
|
||||||
|
</span>
|
||||||
|
<RungSelect
|
||||||
|
value={rung}
|
||||||
|
ladder={ladder}
|
||||||
|
disabled={!settings.enabled}
|
||||||
|
onChange={(level) =>
|
||||||
|
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShardVisibility() {
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [defaults, setDefaults] = useState(null)
|
||||||
|
const [ladder, setLadder] = useState([])
|
||||||
|
const [lockedFields, setLockedFields] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = await api.admin.getShardVisibility()
|
||||||
|
setConfig(data.features)
|
||||||
|
setDefaults(data.defaults)
|
||||||
|
setLadder(data.ladder || [])
|
||||||
|
setLockedFields(data.lockedFields || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not load visibility settings.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
function patch(name, changes) {
|
||||||
|
setMsg('')
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = { ...prev[name], ...changes }
|
||||||
|
// `fieldRules` in the API is `fields` in the effective config.
|
||||||
|
if (changes.fieldRules) {
|
||||||
|
next.fields = changes.fieldRules
|
||||||
|
delete next.fieldRules
|
||||||
|
}
|
||||||
|
return { ...prev, [name]: next }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const body = {}
|
||||||
|
for (const [name, s] of Object.entries(config)) {
|
||||||
|
body[name] = {
|
||||||
|
enabled: s.enabled,
|
||||||
|
audience: s.audience,
|
||||||
|
stream: s.stream,
|
||||||
|
fieldRules: s.fields || {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = await api.admin.saveShardVisibility(body)
|
||||||
|
setConfig(data.features)
|
||||||
|
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToDefaults() {
|
||||||
|
setMsg('')
|
||||||
|
setConfig(structuredClone(defaults))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error && !config) return <ErrorState message={error} onRetry={load} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<header>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||||
|
Shard visibility
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||||
|
Choose who can see each shard surface on the public site, and how much detail they get.
|
||||||
|
Turning a feature off hides it entirely — its pages return “not found” rather than
|
||||||
|
revealing that it exists. “Live updates” controls whether the feature streams changes in
|
||||||
|
real time; the pages still work without it, they just refresh on load.
|
||||||
|
</p>
|
||||||
|
{lockedFields.length > 0 && (
|
||||||
|
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||||
|
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong> —
|
||||||
|
game account names and website user ids are never shown below admin, on any surface. They
|
||||||
|
aren’t visible in game either, so publishing them would disclose something the shard
|
||||||
|
itself doesn’t.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{Object.entries(config).map(([name, settings]) => (
|
||||||
|
<FeatureRow
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
settings={settings}
|
||||||
|
defaults={defaults?.[name]}
|
||||||
|
ladder={ladder}
|
||||||
|
onPatch={patch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
|
||||||
|
Restore defaults
|
||||||
|
</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal file
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
|
||||||
|
// this panel exists for the three things a restart cannot do:
|
||||||
|
//
|
||||||
|
// • point it at a different tree,
|
||||||
|
// • apply a map change without restarting, and
|
||||||
|
// • answer a refresh that was parsed but deliberately NOT applied because it
|
||||||
|
// would remove a facet.
|
||||||
|
//
|
||||||
|
// That last one is the reason the panel is worth building. Losing a facet looks
|
||||||
|
// exactly like a half-copied or mid-update tree, and boot cannot tell them
|
||||||
|
// apart — so it stages the decision for a human instead of guessing. Until
|
||||||
|
// someone decides here, the site keeps serving the atlas it already had.
|
||||||
|
|
||||||
|
// A refresh reports its outcome rather than throwing (the boot path must never
|
||||||
|
// be stopped by a bad tree), so these are answers, not errors — the panel says
|
||||||
|
// what happened in the shard's terms instead of showing a failure box.
|
||||||
|
const OUTCOME = {
|
||||||
|
imported: (r) =>
|
||||||
|
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
|
||||||
|
unchanged: (r) =>
|
||||||
|
r.reason === 'refresh previously rejected'
|
||||||
|
? 'Unchanged — this exact tree was already reviewed and declined.'
|
||||||
|
: 'Unchanged — the tree matches what is already loaded.',
|
||||||
|
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
|
||||||
|
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
|
||||||
|
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
|
||||||
|
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
|
||||||
|
rejected: () => 'Declined. It will not be offered again until the tree changes.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
|
||||||
|
|
||||||
|
function Row({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 16,
|
||||||
|
padding: '7px 0',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
fontSize: '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dim">{label}</span>
|
||||||
|
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingReview({ pending, busy, onApprove, onReject }) {
|
||||||
|
const declined = pending.status === 'rejected'
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 16,
|
||||||
|
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
|
||||||
|
</h3>
|
||||||
|
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||||
|
{declined ? (
|
||||||
|
<>
|
||||||
|
This tree was reviewed and declined, so it is not offered again until the files change.
|
||||||
|
Approving now applies it anyway.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
|
||||||
|
</strong>
|
||||||
|
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
|
||||||
|
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
|
||||||
|
not applied. Approving re-parses the tree as it is right now — if you have since fixed the
|
||||||
|
mount, what lands is the corrected import.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
|
||||||
|
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
|
||||||
|
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
|
||||||
|
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
|
||||||
|
Approve and import
|
||||||
|
</button>
|
||||||
|
{!declined && (
|
||||||
|
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
|
||||||
|
Keep the current atlas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpawnAtlas() {
|
||||||
|
const [status, setStatus] = useState(null)
|
||||||
|
const [path, setPath] = useState('')
|
||||||
|
const [force, setForce] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = await api.admin.atlas.status()
|
||||||
|
setStatus(data)
|
||||||
|
setPath(data.path || '')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not load atlas status.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
// Every mutating action shares this: run it, report what it said, then reload
|
||||||
|
// status so the panel reflects the world rather than what we assumed happened.
|
||||||
|
async function run(action, fn) {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await fn()
|
||||||
|
setMsg(describe(result))
|
||||||
|
const fresh = await api.admin.atlas.status()
|
||||||
|
setStatus(fresh)
|
||||||
|
setPath(fresh.path || '')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || `Could not ${action}.`)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePath() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const fresh = await api.admin.atlas.setPath(path.trim())
|
||||||
|
setStatus(fresh)
|
||||||
|
setPath(fresh.path || '')
|
||||||
|
setMsg(
|
||||||
|
fresh.path === ''
|
||||||
|
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
|
||||||
|
: fresh.treeReadable
|
||||||
|
? 'Saved. The tree is readable — import when you are ready.'
|
||||||
|
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save the path.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error && !status) return <ErrorState message={error} />
|
||||||
|
|
||||||
|
const counts = status?.counts || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<header>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||||
|
Spawn atlas
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||||
|
The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
|
||||||
|
It refreshes itself on every server start; everything here is for the times you don’t want
|
||||||
|
to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
|
||||||
|
shard state, and stays complete while the shard is down.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{status?.pending && (
|
||||||
|
<PendingReview
|
||||||
|
pending={status.pending}
|
||||||
|
busy={busy}
|
||||||
|
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
|
||||||
|
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||||
|
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
What is loaded
|
||||||
|
</h3>
|
||||||
|
<Row label="Imported">
|
||||||
|
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
|
||||||
|
</Row>
|
||||||
|
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
|
||||||
|
{counts && (
|
||||||
|
<>
|
||||||
|
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
|
||||||
|
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
|
||||||
|
<Row label="Regions / landmarks">
|
||||||
|
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
|
||||||
|
</Row>
|
||||||
|
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Row label="Tree readable">
|
||||||
|
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
|
||||||
|
</Row>
|
||||||
|
<Row label="Tree changed since import">
|
||||||
|
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||||
|
</Row>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||||
|
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
ServUO tree
|
||||||
|
</h3>
|
||||||
|
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||||
|
Where the website reads the shard’s spawn files from — the same host, a bind mount or a
|
||||||
|
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
|
||||||
|
mount can move without a redeploy. Leave it blank to turn the atlas off.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={path}
|
||||||
|
onChange={(e) => setPath(e.target.value)}
|
||||||
|
placeholder="/srv/servuo"
|
||||||
|
style={{ flex: '1 1 320px', minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
|
||||||
|
Save path
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||||
|
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
Re-import
|
||||||
|
</h3>
|
||||||
|
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||||
|
Applies a map change without restarting. An unchanged tree costs nothing — the source files
|
||||||
|
are hashed first and skipped when they match. A refresh that would remove a facet still
|
||||||
|
comes back here for approval rather than being applied.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sq"
|
||||||
|
disabled={busy || !status?.configured}
|
||||||
|
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
|
||||||
|
>
|
||||||
|
{busy ? 'Working…' : 'Import now'}
|
||||||
|
</button>
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
|
||||||
|
Re-import even if the tree is unchanged
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(msg || error) && (
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } 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'
|
||||||
@@ -84,6 +84,38 @@ function Standing({ scope }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||||
function Houses({ scope }) {
|
function Houses({ scope }) {
|
||||||
const { data } = useAsync(() => scope.houses(), [scope])
|
const { data } = useAsync(() => scope.houses(), [scope])
|
||||||
@@ -96,31 +128,118 @@ function Houses({ scope }) {
|
|||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{data.map((h) => (
|
{data.map((h) => (
|
||||||
<li
|
<HouseRow key={h.serial} house={h} />
|
||||||
key={h.serial}
|
))}
|
||||||
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)' }}
|
</ul>
|
||||||
>
|
)}
|
||||||
<div style={{ minWidth: 0 }}>
|
</section>
|
||||||
<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>}
|
|
||||||
|
// 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 user’s 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 user’s 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 & 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>
|
||||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
{d.userAgent || '—'} · last used {fmt(d.lastUsedAt)} · expires {fmt(d.expiresAt)}
|
||||||
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
|
||||||
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
|
||||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
Revoke
|
||||||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
</button>
|
||||||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -176,6 +295,7 @@ export default function UserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SecurityAdmin userId={id} />
|
||||||
<ShardSections scope={scope} />
|
<ShardSections scope={scope} />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
if (p.added) cls = 'diff-add'
|
||||||
|
else if (p.removed) cls = 'diff-del'
|
||||||
|
return (
|
||||||
|
<span key={`${i}:${p.value}`} className={cls}>
|
||||||
{p.value}
|
{p.value}
|
||||||
</span>
|
</span>
|
||||||
))
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function AcceptInvite() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { refresh } = useAuth()
|
const { refresh } = useAuth()
|
||||||
|
|
||||||
const [invite, setInvite] = useState(null) // { email, role }
|
const [invite, setInvite] = useState(null) // fields email and role
|
||||||
const [loadErr, setLoadErr] = useState('')
|
const [loadErr, setLoadErr] = useState('')
|
||||||
const [signupOk, setSignupOk] = useState(false)
|
const [signupOk, setSignupOk] = useState(false)
|
||||||
|
|
||||||
|
|||||||
74
client/src/routes/player/ForgotPassword.jsx
Normal file
74
client/src/routes/player/ForgotPassword.jsx
Normal 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>, we’ve 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 we’ll 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
221
client/src/routes/player/PlayerAppeals.jsx
Normal file
221
client/src/routes/player/PlayerAppeals.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,48 @@ export default function PlayerLogin() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (ssoTotp) {
|
if (ssoTotp) {
|
||||||
const { returnTo } = await ssoLoginTotp(code)
|
// Trust works on the SSO second factor too. On the mobile bridge this page
|
||||||
navigate(returnTo || '/account', { replace: true })
|
// is running inside the app's Custom Tab, so the cookie set here is what
|
||||||
|
// lets the next app sign-in skip the code.
|
||||||
|
const data = await ssoLoginTotp(code.trim(), { trustDevice })
|
||||||
|
// 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.
|
||||||
|
// This wins over the trust-cap prompt: the sign-in itself succeeded and the
|
||||||
|
// deep link is single-use, so stalling here to manage devices would strand
|
||||||
|
// the app. An over-cap user simply isn't trusted and can prune the list
|
||||||
|
// from Account → Trusted Devices.
|
||||||
|
if (data.redirect) {
|
||||||
|
window.location.href = data.redirect
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const to = data.returnTo || '/account'
|
||||||
|
if (data.trustLimitReached) {
|
||||||
|
setTrustLimit({ devices: data.devices || [], dest: to })
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(to, { 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 +158,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' }}>
|
||||||
|
<Link to="/account/forgot" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
|
Forgot your password?
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{canRegister && (
|
||||||
|
<p className="sans" style={{ margin: '8px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
New here?{' '}
|
New here?{' '}
|
||||||
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
Create an account
|
Create an account
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)
|
)}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
|
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
|
||||||
@@ -151,13 +202,43 @@ 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>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode={useRecovery ? 'text' : 'numeric'}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
autoFocus
|
||||||
|
placeholder={useRecovery ? 'xxxxx-xxxxx' : '6-digit code'}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||||
Enter the code from your authenticator app.
|
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{/* Offered on the SSO second factor too — the trust is on the device,
|
||||||
|
not on how the first factor was proved. Inside the app's Custom Tab
|
||||||
|
this is also what trusts the device for future native sign-ins. */}
|
||||||
|
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
|
||||||
|
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||||
|
Trust this device for 30 days (skip the code next time)
|
||||||
|
</label>
|
||||||
|
{/* Recovery codes remain password-login only: the SSO second step
|
||||||
|
verifies an authenticator code against the staged challenge. */}
|
||||||
|
{!ssoTotp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(error || (stage === 'creds' && ssoError)) && (
|
{(error || (stage === 'creds' && ssoError)) && (
|
||||||
@@ -167,7 +248,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 +271,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,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
|
||||||
@@ -39,6 +41,7 @@ const NAV = [
|
|||||||
const TITLES = {
|
const TITLES = {
|
||||||
'/player': 'Characters',
|
'/player': 'Characters',
|
||||||
'/account': 'Account',
|
'/account': 'Account',
|
||||||
|
'/account/appeals': 'Appeals',
|
||||||
}
|
}
|
||||||
|
|
||||||
const navBtnBase = {
|
const navBtnBase = {
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
115
client/src/routes/player/ResetPassword.jsx
Normal file
115
client/src/routes/player/ResetPassword.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
310
client/src/routes/public/Atlas.jsx
Normal file
310
client/src/routes/public/Atlas.jsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
||||||
|
// spawn, where, and which champion altars are configured. There is no live feed
|
||||||
|
// here and no `connected` indicator, deliberately — this is parsed from the
|
||||||
|
// shard's own files and stays complete while the shard is down.
|
||||||
|
//
|
||||||
|
// Facet names come from the shard's data, never from a list in this file. A
|
||||||
|
// shard running custom maps gets its own names in the filter with no code
|
||||||
|
// change (docs/link/v3.md §6.1 R2).
|
||||||
|
|
||||||
|
const PAGE = 50
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'creatures', label: 'Creatures' },
|
||||||
|
{ key: 'champions', label: 'Champion altars' },
|
||||||
|
{ key: 'places', label: 'Places' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Chip({ active, onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '5px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||||
|
background: active ? 'var(--accent)' : 'transparent',
|
||||||
|
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreatureCard({ creature }) {
|
||||||
|
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
|
||||||
|
className="panel"
|
||||||
|
style={{
|
||||||
|
padding: '13px 15px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div
|
||||||
|
className="display"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.98rem',
|
||||||
|
color: 'var(--head)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{creature.name}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||||
|
{facets.length === 0
|
||||||
|
? '—'
|
||||||
|
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||||
|
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
||||||
|
{num(creature.points)} spawners
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The creature list owns its own paging rather than going through useAsync: a
|
||||||
|
// "load more" appends to what is already on screen, which a hook that resets to
|
||||||
|
// `{ loading: true, data: null }` on every dependency change cannot express.
|
||||||
|
function Creatures({ q, facet }) {
|
||||||
|
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
||||||
|
const [more, setMore] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (offset) => {
|
||||||
|
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
||||||
|
return page
|
||||||
|
},
|
||||||
|
[q, facet],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
setState({ loading: true, error: null, items: [], total: 0 })
|
||||||
|
load(0)
|
||||||
|
.then((page) => {
|
||||||
|
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
||||||
|
})
|
||||||
|
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
setMore(true)
|
||||||
|
try {
|
||||||
|
const page = await load(state.items.length)
|
||||||
|
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
||||||
|
} catch {
|
||||||
|
// A failed "load more" leaves what is already on screen alone; the button
|
||||||
|
// simply stays available to retry.
|
||||||
|
} finally {
|
||||||
|
setMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.loading) return <Loading />
|
||||||
|
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
||||||
|
if (state.items.length === 0) {
|
||||||
|
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||||
|
Showing {num(state.items.length)} of {num(state.total)}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{state.items.map((c) => (
|
||||||
|
<CreatureCard key={c.slug} creature={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{state.items.length < state.total && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||||
|
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||||
|
{more ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
||||||
|
// live board ("it is on level 3 right now") is a different page, /site/champs,
|
||||||
|
// fed by the sidecar. Both exist; they are not the same thing.
|
||||||
|
function Champions({ facet }) {
|
||||||
|
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
||||||
|
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.map((champ) => (
|
||||||
|
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
||||||
|
{champ.label || champ.name}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||||
|
{champ.facet}
|
||||||
|
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
||||||
|
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regions and landmarks together: both answer "where is that?", and splitting
|
||||||
|
// them into two tabs would make the visitor guess which list a name lives in.
|
||||||
|
function Places({ q, facet }) {
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
||||||
|
[q, facet],
|
||||||
|
)
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!data) return []
|
||||||
|
const [regions, landmarks] = data
|
||||||
|
return [
|
||||||
|
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
||||||
|
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
||||||
|
].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load places right now." />
|
||||||
|
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Atlas() {
|
||||||
|
const [tab, setTab] = useState('creatures')
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [facet, setFacet] = useState('')
|
||||||
|
const meta = useAsync(() => api.atlas.meta())
|
||||||
|
|
||||||
|
// Debounced: typing "lizardman" should be one request, not nine.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setQ(input.trim()), 250)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [input])
|
||||||
|
|
||||||
|
const facets = meta.data?.facets || []
|
||||||
|
const counts = meta.data?.counts || null
|
||||||
|
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Bestiary"
|
||||||
|
title="Spawn atlas"
|
||||||
|
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* The atlas is only as good as its placement rate, so the page states
|
||||||
|
it rather than implying every spawner resolved to a named place. */}
|
||||||
|
{counts && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||||
|
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
||||||
|
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
||||||
|
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
||||||
|
: ''}
|
||||||
|
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||||
|
{t.label}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab !== 'champions' && (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
||||||
|
style={{ width: '100%', marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{facets.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||||
|
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
||||||
|
All facets
|
||||||
|
</Chip>
|
||||||
|
{facets.map((f) => (
|
||||||
|
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
||||||
|
{f}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
||||||
|
{!meta.error && !meta.loading && !imported && (
|
||||||
|
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!meta.error && imported && (
|
||||||
|
<>
|
||||||
|
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
||||||
|
{tab === 'champions' && <Champions facet={facet} />}
|
||||||
|
{tab === 'places' && <Places q={q} facet={facet} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
201
client/src/routes/public/AtlasCreature.jsx
Normal file
201
client/src/routes/public/AtlasCreature.jsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// One creature: where it spawns, and what spawns alongside it.
|
||||||
|
//
|
||||||
|
// `places` is the point of the page — the aggregate that turns 62 raw
|
||||||
|
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
||||||
|
// available underneath for the reader who actually wants a coordinate, but they
|
||||||
|
// are secondary and collapsed by default.
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||||
|
|
||||||
|
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
||||||
|
function delay(min, max) {
|
||||||
|
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
||||||
|
if (min === max) return fmt(min)
|
||||||
|
return `${fmt(min)}–${fmt(max)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({ title, right, children }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 18 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Places({ places }) {
|
||||||
|
if (places.length === 0) {
|
||||||
|
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{places.map((place) => (
|
||||||
|
<div
|
||||||
|
key={`${place.facet}:${place.label}`}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '6px 0',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
fontSize: '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none' }}>
|
||||||
|
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
||||||
|
{num(place.maxAlive)} at once
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spawners({ spawners, truncated }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
if (spawners.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Individual spawners"
|
||||||
|
right={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sans"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||||
|
>
|
||||||
|
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{open && (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
||||||
|
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{spawners.map((s) => (
|
||||||
|
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
||||||
|
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{truncated && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||||
|
Only the largest spawners are listed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AtlasCreature() {
|
||||||
|
const { slug } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
||||||
|
|
||||||
|
// A 404 here means "no such creature in this atlas", which is a real answer
|
||||||
|
// and not a failure — a visitor following a stale link deserves to be told
|
||||||
|
// that plainly rather than shown a generic error box.
|
||||||
|
const missing = error?.status === 404 || error?.message === 'Not Found'
|
||||||
|
|
||||||
|
const facets = useMemo(
|
||||||
|
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
||||||
|
[data],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<p className="sans" style={{ marginBottom: 8 }}>
|
||||||
|
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||||
|
← Spawn atlas
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
||||||
|
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Bestiary"
|
||||||
|
title={data.name}
|
||||||
|
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Panel
|
||||||
|
title="Where it spawns"
|
||||||
|
right={
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||||
|
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Places places={data.places || []} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
||||||
|
|
||||||
|
{data.alsoHere?.length > 0 && (
|
||||||
|
<Panel title="Shares a spawner with">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{data.alsoHere.map((other) => (
|
||||||
|
<Link
|
||||||
|
key={other.slug}
|
||||||
|
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '4px 11px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{other.name} <span className="dim">×{num(other.shared)}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -91,6 +91,11 @@ function ChampDetail({ s }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// champion
|
// champion
|
||||||
|
let progress = ''
|
||||||
|
if (s.status === 'cooldown') progress = until(s.restartAt) || 'restarting'
|
||||||
|
else if (s.status === 'active') {
|
||||||
|
progress = `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="sans" style={line}>
|
<div className="sans" style={line}>
|
||||||
@@ -98,13 +103,7 @@ function ChampDetail({ s }) {
|
|||||||
Level {s.level ?? 0}
|
Level {s.level ?? 0}
|
||||||
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
|
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>{progress}</span>
|
||||||
{s.status === 'cooldown'
|
|
||||||
? until(s.restartAt) || 'restarting'
|
|
||||||
: s.status === 'active'
|
|
||||||
? `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
|
|
||||||
: ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{s.status === 'active' && (
|
{s.status === 'active' && (
|
||||||
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
|
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ function TermHistory({ city }) {
|
|||||||
)}
|
)}
|
||||||
{data && data.length > 0 && (
|
{data && data.length > 0 && (
|
||||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
{data.map((t, i) => (
|
{data.map((t) => (
|
||||||
<li key={i} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
<li key={`${t.startedAt}-${t.governor?.name ?? 'vacant'}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
||||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{t.governor?.name || 'Vacant'}
|
{t.governor?.name || 'Vacant'}
|
||||||
</span>
|
</span>
|
||||||
@@ -100,6 +100,7 @@ function TermHistory({ city }) {
|
|||||||
function CityCard({ c }) {
|
function CityCard({ c }) {
|
||||||
const phase = PHASE[c.electionPhase] || null
|
const phase = PHASE[c.electionPhase] || null
|
||||||
const gov = c.governor
|
const gov = c.governor
|
||||||
|
const candidatePlural = c.candidates === 1 ? '' : 's'
|
||||||
return (
|
return (
|
||||||
<div className="panel" style={{ padding: 18 }}>
|
<div className="panel" style={{ padding: 18 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
@@ -127,7 +128,7 @@ function CityCard({ c }) {
|
|||||||
|
|
||||||
{c.electionPhase && c.electionPhase !== 'none' && (
|
{c.electionPhase && c.electionPhase !== 'none' && (
|
||||||
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
||||||
{c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'}
|
{c.candidates ? `${c.candidates} candidate${candidatePlural}` : 'No candidates yet'}
|
||||||
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
240
client/src/routes/public/Leaderboards.jsx
Normal file
240
client/src/routes/public/Leaderboards.jsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
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'
|
||||||
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
|
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
|
||||||
|
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
|
||||||
|
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
|
||||||
|
// players build over months, and none of them visible anywhere but an in-game gump
|
||||||
|
// until now.
|
||||||
|
//
|
||||||
|
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
|
||||||
|
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
|
||||||
|
// system, so live frames are merged over the fetched set by system key rather than
|
||||||
|
// replacing it.
|
||||||
|
const POINTS_KINDS = new Set(['points.board'])
|
||||||
|
|
||||||
|
// A board's display name may arrive as a literal (`nameString`), a cliloc id
|
||||||
|
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
|
||||||
|
// table on the site, so a cliloc-only board falls back to humanising its own
|
||||||
|
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
|
||||||
|
// "Clean Up Britannia"). Better than showing a bare number.
|
||||||
|
const humanise = (key) =>
|
||||||
|
String(key || '')
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/^./, (c) => c.toUpperCase())
|
||||||
|
|
||||||
|
const boardTitle = (b) => b.nameString || humanise(b.system)
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||||
|
|
||||||
|
// Merge live frames over the fetched boards. Newest frame per system wins; a
|
||||||
|
// system that has never appeared in either is simply absent.
|
||||||
|
function mergeBoards(fetched, events) {
|
||||||
|
const bySystem = new Map()
|
||||||
|
for (const b of Array.isArray(fetched) ? fetched : []) {
|
||||||
|
if (b && b.system) bySystem.set(b.system, b)
|
||||||
|
}
|
||||||
|
// Events arrive newest-first, so walk backwards and let the newest land last.
|
||||||
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev && ev.system) bySystem.set(ev.system, ev)
|
||||||
|
}
|
||||||
|
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Medal({ rank }) {
|
||||||
|
// Gold / silver / bronze for the podium, plain for the rest.
|
||||||
|
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="display"
|
||||||
|
style={{
|
||||||
|
flex: 'none', width: 26, textAlign: 'right', color: tone,
|
||||||
|
fontSize: rank <= 3 ? '1rem' : '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One ranked player. `name` is absent rather than empty when an admin has gated
|
||||||
|
// the leaderboards `name` field above this viewer's rung — the row still renders,
|
||||||
|
// because the standing itself is the point.
|
||||||
|
function Entry({ entry, best }) {
|
||||||
|
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||||
|
<Medal rank={entry.rank} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
color: entry.name ? 'var(--ink)' : 'var(--muted)',
|
||||||
|
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.name || 'Name hidden'}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||||
|
{num(entry.points)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Board({ board }) {
|
||||||
|
const { siteTitle } = useSite()
|
||||||
|
const top = Array.isArray(board.top) ? board.top : []
|
||||||
|
// Bars are relative to the board leader, not to maxPoints: most systems have no
|
||||||
|
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
|
||||||
|
// which would render every bar as a stub.
|
||||||
|
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||||
|
{boardTitle(board)}
|
||||||
|
</h2>
|
||||||
|
{Number.isFinite(board.players) && (
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
|
||||||
|
{num(board.players)} ranked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{top.length === 0 ? (
|
||||||
|
// A board nobody has scored on still gets a row, so the page reads as a set
|
||||||
|
// of standings waiting to be filled rather than a stack of blanks. It is
|
||||||
|
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
|
||||||
|
// a score goes — because a placeholder that looked like a real standing would
|
||||||
|
// be a fabricated one. The first real entry replaces it.
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
color: 'var(--muted)', fontSize: '0.86rem',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{siteTitle}
|
||||||
|
</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>—</span>
|
||||||
|
</div>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||||
|
Nobody has earned points here yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{top.map((entry) => (
|
||||||
|
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||||
|
Maximum {num(board.maxPoints)} points
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Leaderboards() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.points())
|
||||||
|
// Buffer generously: a single sweep can emit a frame for every system at once,
|
||||||
|
// and a board dropped from the buffer would silently revert to its fetched copy.
|
||||||
|
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const boards = useMemo(() => mergeBoards(data, events), [data, events])
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return boards
|
||||||
|
// Match the board name, the raw system key, or any ranked player on it — the
|
||||||
|
// last is what makes the filter useful ("where do I appear?").
|
||||||
|
return boards.filter(
|
||||||
|
(b) =>
|
||||||
|
boardTitle(b).toLowerCase().includes(q) ||
|
||||||
|
String(b.system).toLowerCase().includes(q) ||
|
||||||
|
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
|
||||||
|
)
|
||||||
|
}, [boards, query])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Live"
|
||||||
|
title="Leaderboards"
|
||||||
|
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||||
|
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the leaderboards right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && boards.length === 0 && (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>
|
||||||
|
The shard has not published any leaderboards yet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && boards.length > 0 && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Filter by board or player name…"
|
||||||
|
aria-label="Filter leaderboards"
|
||||||
|
style={{ maxWidth: 340, marginBottom: 14 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<p className="sans dim">No board or ranked player matches “{query}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
|
||||||
|
{shown.map((board) => (
|
||||||
|
<Board key={board.system} board={board} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
325
client/src/routes/public/Market.jsx
Normal file
325
client/src/routes/public/Market.jsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// ── The player-vendor marketplace ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// What every player vendor on the shard is selling, for how much, and where it
|
||||||
|
// is standing — the same index the in-game Vendor Search gump reads, honouring
|
||||||
|
// the same per-vendor opt-out, reachable without logging in to the game.
|
||||||
|
//
|
||||||
|
// Three things this page must be honest about, all of them consequences of how
|
||||||
|
// the data is gathered (docs/link/v3.md §8):
|
||||||
|
//
|
||||||
|
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
|
||||||
|
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
|
||||||
|
// A page that implied live prices would send people across the world to a
|
||||||
|
// vendor whose item sold twenty minutes ago.
|
||||||
|
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
|
||||||
|
// publishes only the first N, and saying so beats presenting a partial shop
|
||||||
|
// as complete.
|
||||||
|
// • **An item may have no name.** On a shard whose operator has not converted
|
||||||
|
// a cliloc table, `displayName` is null and the honest render is the item id
|
||||||
|
// — not an invented name.
|
||||||
|
//
|
||||||
|
// There is deliberately no live feed here. The market feature's SSE stream ships
|
||||||
|
// disabled: a firehose of whole vendor inventories would be the site's single
|
||||||
|
// biggest bandwidth consumer, and nothing on this page needs it.
|
||||||
|
|
||||||
|
const PAGE = 50
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||||
|
|
||||||
|
const SORTS = [
|
||||||
|
{ key: 'price_asc', label: 'Cheapest' },
|
||||||
|
{ key: 'price_desc', label: 'Priciest' },
|
||||||
|
{ key: 'recent', label: 'Recently seen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
|
||||||
|
// this is a worst case rather than an average — which is the number worth
|
||||||
|
// showing, because the one stale shop is the one that wastes a trip.
|
||||||
|
function staleness(staleAt) {
|
||||||
|
if (!staleAt) return null
|
||||||
|
const ms = Date.now() - new Date(staleAt).getTime()
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return null
|
||||||
|
const mins = Math.round(ms / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
|
||||||
|
const hours = Math.round(mins / 60)
|
||||||
|
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||||
|
return `${Math.round(hours / 24)} days ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The item's name, or an honest statement that we do not have one. Never a
|
||||||
|
// fabricated label — "Item 3922" would be indistinguishable from a real name.
|
||||||
|
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
|
||||||
|
|
||||||
|
function Chip({ active, onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '5px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||||
|
background: active ? 'var(--accent)' : 'transparent',
|
||||||
|
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListingRow({ listing }) {
|
||||||
|
const v = listing.vendor || {}
|
||||||
|
// `location` is one field the admin can gate away wholesale, so everything
|
||||||
|
// that reads from it has to tolerate its absence rather than assuming a map.
|
||||||
|
const loc = v.location || null
|
||||||
|
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div
|
||||||
|
className="display"
|
||||||
|
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
|
||||||
|
{itemLabel(listing)}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||||
|
{v.serial ? (
|
||||||
|
<Link to={`/site/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
|
||||||
|
{v.shopName || 'an unnamed shop'}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
v.shopName || 'an unnamed shop'
|
||||||
|
)}
|
||||||
|
{v.ownerName ? ` · ${v.ownerName}` : ''}
|
||||||
|
{where ? ` · ${where}` : ''}
|
||||||
|
{/* Priced by the container it sits in, exactly as the in-game search
|
||||||
|
reports it — the price buys the whole container, not this item. */}
|
||||||
|
{listing.child ? ' · sold with its container' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||||
|
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Market() {
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [map, setMap] = useState('')
|
||||||
|
const [region, setRegion] = useState('')
|
||||||
|
const [sort, setSort] = useState('price_asc')
|
||||||
|
const [minPrice, setMinPrice] = useState('')
|
||||||
|
const [maxPrice, setMaxPrice] = useState('')
|
||||||
|
// Applied prices are separate from the typed ones so the search fires when the
|
||||||
|
// user is done, not on every digit of "250000".
|
||||||
|
const [prices, setPrices] = useState({ min: '', max: '' })
|
||||||
|
|
||||||
|
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||||
|
const [more, setMore] = useState(false)
|
||||||
|
|
||||||
|
const meta = useAsync(() => api.shard.marketMeta())
|
||||||
|
|
||||||
|
// Debounced: typing "vanquishing" should be one request, not eleven — and the
|
||||||
|
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setQ(input.trim()), 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [input])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [minPrice, maxPrice])
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
(offset) =>
|
||||||
|
api.shard.market({
|
||||||
|
q,
|
||||||
|
map,
|
||||||
|
region,
|
||||||
|
sort,
|
||||||
|
minPrice: prices.min,
|
||||||
|
maxPrice: prices.max,
|
||||||
|
limit: PAGE,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
[q, map, region, sort, prices],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||||
|
load(0)
|
||||||
|
.then((page) => {
|
||||||
|
if (!alive) return
|
||||||
|
setState({
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
listings: page.listings || [],
|
||||||
|
total: page.total || 0,
|
||||||
|
staleAt: page.staleAt || null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
setMore(true)
|
||||||
|
try {
|
||||||
|
const page = await load(state.listings.length)
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
listings: [...s.listings, ...(page.listings || [])],
|
||||||
|
total: page.total ?? s.total,
|
||||||
|
staleAt: page.staleAt ?? s.staleAt,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
// A failed "load more" leaves what is on screen alone; the button stays
|
||||||
|
// available to retry.
|
||||||
|
} finally {
|
||||||
|
setMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maps = meta.data?.maps || []
|
||||||
|
const regions = meta.data?.regions || []
|
||||||
|
const age = staleness(state.staleAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Marketplace"
|
||||||
|
title="Player vendors"
|
||||||
|
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Not decoration. The sweep is round-robin, so the index is inherently
|
||||||
|
up to one full cycle old and the page has to say so. */}
|
||||||
|
{age && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||||
|
Prices last refreshed {age}
|
||||||
|
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
|
||||||
|
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Search listings…"
|
||||||
|
style={{ width: '100%', marginBottom: 10 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={minPrice}
|
||||||
|
onChange={(e) => setMinPrice(e.target.value)}
|
||||||
|
placeholder="Min price"
|
||||||
|
style={{ maxWidth: 140 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={maxPrice}
|
||||||
|
onChange={(e) => setMaxPrice(e.target.value)}
|
||||||
|
placeholder="Max price"
|
||||||
|
style={{ maxWidth: 140 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
|
||||||
|
{s.label}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Facet and region names come from the shard's own data, never a list in
|
||||||
|
this file — a shard running custom maps gets its own names here with
|
||||||
|
no code change (docs/link/v3.md §6.1 R2). */}
|
||||||
|
{maps.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
|
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
|
||||||
|
{maps.map((m) => (
|
||||||
|
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{regions.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={region}
|
||||||
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
|
style={{ width: '100%', marginBottom: 18 }}
|
||||||
|
>
|
||||||
|
<option value="">Anywhere</option>
|
||||||
|
{regions.map((r) => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.loading && <Loading />}
|
||||||
|
{state.error && <ErrorState message="Could not load the marketplace right now." />}
|
||||||
|
|
||||||
|
{!state.loading && !state.error && state.listings.length === 0 && (
|
||||||
|
<EmptyState>
|
||||||
|
{meta.data?.vendors
|
||||||
|
? 'Nothing on the shard matches that.'
|
||||||
|
: 'No player vendors have been indexed yet.'}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!state.loading && !state.error && state.listings.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||||
|
Showing {num(state.listings.length)} of {num(state.total)}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{state.listings.map((l) => (
|
||||||
|
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{state.listings.length < state.total && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||||
|
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||||
|
{more ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
client/src/routes/public/MarketVendor.jsx
Normal file
102
client/src/routes/public/MarketVendor.jsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// One player vendor: where to find it and everything it is selling.
|
||||||
|
//
|
||||||
|
// The page a search result points at. Two states it has to render honestly and
|
||||||
|
// which the search list cannot (docs/link/v3.md §8):
|
||||||
|
//
|
||||||
|
// • `truncated` — the shop holds more than the shard publishes per frame. A
|
||||||
|
// commodity reseller with thousands of stacks is a real thing, and showing
|
||||||
|
// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
|
||||||
|
// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
|
||||||
|
// which case there is nothing to render and the page says so rather than
|
||||||
|
// showing an empty coordinate.
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||||
|
|
||||||
|
const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
|
||||||
|
|
||||||
|
export default function MarketVendor() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body"><Loading /></div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<ErrorState message="That shop is not in the index — it may have been dismissed or hidden." />
|
||||||
|
<p style={{ marginTop: 16 }}>
|
||||||
|
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loc = data.location || null
|
||||||
|
const items = data.items || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow={data.ownerName ? `Run by ${data.ownerName}` : 'Player vendor'}
|
||||||
|
title={data.shopName || 'An unnamed shop'}
|
||||||
|
lead={
|
||||||
|
loc
|
||||||
|
? [loc.house, loc.region, loc.map].filter(Boolean).join(' · ') +
|
||||||
|
(Number.isFinite(loc.x) ? ` — ${loc.x}, ${loc.y}` : '')
|
||||||
|
: 'This shard does not publish vendor locations.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '-12px 0 18px' }}>
|
||||||
|
{data.truncated
|
||||||
|
? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
|
||||||
|
: `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
|
||||||
|
{data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState>This shop has nothing priced for sale.</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<div
|
||||||
|
key={i.serial}
|
||||||
|
className="panel"
|
||||||
|
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
|
||||||
|
>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||||
|
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
|
||||||
|
{itemLabel(i)}
|
||||||
|
{i.child ? <span className="dim"> · sold with its container</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ flex: 'none', color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||||
|
{num(i.price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ marginTop: 20 }}>
|
||||||
|
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
341
client/src/routes/public/Rules.jsx
Normal file
341
client/src/routes/public/Rules.jsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
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'
|
||||||
|
|
||||||
|
// The shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
|
||||||
|
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
|
||||||
|
// there is nothing to merge — latest wins).
|
||||||
|
//
|
||||||
|
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
|
||||||
|
// it cannot drift the way a hand-written rules page does. That is the whole point
|
||||||
|
// of the feature, and the page says so.
|
||||||
|
const RULESET_KINDS = new Set(['world.ruleset'])
|
||||||
|
|
||||||
|
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
|
||||||
|
// skill. Showing the raw number would be actively misleading.
|
||||||
|
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
|
||||||
|
|
||||||
|
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
|
||||||
|
|
||||||
|
// The systems block is a flat bag of booleans; these are their display names, and
|
||||||
|
// the order here is the order they render. A key the shard sends that we don't
|
||||||
|
// know about still renders, humanised, rather than being silently dropped — a new
|
||||||
|
// plugin must not go invisible against an older client.
|
||||||
|
const SYSTEM_LABELS = {
|
||||||
|
cityLoyalty: 'City Loyalty (governors)',
|
||||||
|
vvv: 'Vice vs Virtue',
|
||||||
|
factions: 'Factions',
|
||||||
|
siege: 'Siege ruleset',
|
||||||
|
chat: 'In-game chat',
|
||||||
|
store: 'Ultima Store',
|
||||||
|
dailyRares: 'Daily rares',
|
||||||
|
honesty: 'Honesty virtue',
|
||||||
|
shadowguard: 'Shadowguard',
|
||||||
|
treasureMaps: 'Treasure maps',
|
||||||
|
vetRewards: 'Veteran rewards',
|
||||||
|
testCenter: 'Test Center',
|
||||||
|
}
|
||||||
|
|
||||||
|
const humanise = (key) =>
|
||||||
|
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
|
||||||
|
|
||||||
|
function Panel({ title, children }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 18 }}>
|
||||||
|
<h2
|
||||||
|
className="display"
|
||||||
|
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A label/value row. Rows whose value is null are dropped by the caller, so a
|
||||||
|
// block never renders a dangling label for something the shard didn't publish.
|
||||||
|
function Row({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '5px 0',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
fontSize: '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
|
||||||
|
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rows({ items }) {
|
||||||
|
const rows = items.filter(([, value]) => value !== null && value !== undefined)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<Row key={label} label={label} value={value} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemPill({ label, on }) {
|
||||||
|
const color = on ? '#8fdcae' : 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 7,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
padding: '5px 11px',
|
||||||
|
borderRadius: 999,
|
||||||
|
color,
|
||||||
|
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
|
||||||
|
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Systems({ systems }) {
|
||||||
|
// Known keys first in their declared order, then anything the shard added that
|
||||||
|
// this build doesn't know about.
|
||||||
|
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
|
||||||
|
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
|
||||||
|
const keys = [...known, ...extra]
|
||||||
|
if (keys.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel title="Systems">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{keys.map((k) => (
|
||||||
|
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Caps({ caps }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Skill & stat caps">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Individual skill cap', tenths(caps.skill)],
|
||||||
|
['Total skill cap', tenths(caps.totalSkill)],
|
||||||
|
['Total stat cap', num(caps.stat)],
|
||||||
|
['Strength cap', num(caps.str)],
|
||||||
|
['Dexterity cap', num(caps.dex)],
|
||||||
|
['Intelligence cap', num(caps.int)],
|
||||||
|
['Strength max', num(caps.strMax)],
|
||||||
|
['Dexterity max', num(caps.dexMax)],
|
||||||
|
['Intelligence max', num(caps.intMax)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountsAndHousing({ accounts, housing, vetRewards }) {
|
||||||
|
const items = []
|
||||||
|
if (accounts) {
|
||||||
|
items.push(['Accounts per IP', num(accounts.perIp)])
|
||||||
|
items.push(['Character slots', num(accounts.charSlots)])
|
||||||
|
items.push([
|
||||||
|
'In-game account creation',
|
||||||
|
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
|
||||||
|
if (vetRewards?.enabled) {
|
||||||
|
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
|
||||||
|
? `${vetRewards.rewardIntervalDays} days`
|
||||||
|
: null])
|
||||||
|
}
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel title="Accounts & housing">
|
||||||
|
<Rows items={items} />
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Champions({ champions }) {
|
||||||
|
const t = champions.rankThresholds
|
||||||
|
return (
|
||||||
|
<Panel title="Champion spawns">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Power scrolls per spawn', num(champions.powerScrolls)],
|
||||||
|
['Stat scrolls per spawn', num(champions.statScrolls)],
|
||||||
|
['Scroll drop chance', pct(champions.scrollChance)],
|
||||||
|
['Transcendence chance', pct(champions.transcendenceChance)],
|
||||||
|
[
|
||||||
|
'Red skulls per rank',
|
||||||
|
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Felucca({ loot }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Felucca bonuses">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Luck bonus', num(loot.feluccaLuckBonus)],
|
||||||
|
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
|
||||||
|
['Max item properties', num(loot.feluccaMaxProps)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vendors({ vendors }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Vendors">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Restock delay', vendors.restockDelayMinutes
|
||||||
|
? `${vendors.restockDelayMinutes} min`
|
||||||
|
: null],
|
||||||
|
['Max items sold at once', num(vendors.maxSell)],
|
||||||
|
['Economy stock amount', num(vendors.economyStockAmount)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pvp({ vvv }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Vice vs Virtue">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Starting silver', num(vvv.startSilver)],
|
||||||
|
['Enhanced rules', vvv.enhancedRules === undefined
|
||||||
|
? null
|
||||||
|
: vvv.enhancedRules ? 'On' : 'Off'],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Schedule({ schedule }) {
|
||||||
|
const items = []
|
||||||
|
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
|
||||||
|
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
|
||||||
|
} else if (schedule.autoSaveEnabled === false) {
|
||||||
|
items.push(['World save', 'Disabled'])
|
||||||
|
}
|
||||||
|
if (schedule.autoRestartEnabled) {
|
||||||
|
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
|
||||||
|
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
|
||||||
|
items.push(['Automatic restart', `${h}:${m} server time`])
|
||||||
|
if (schedule.autoRestartFrequencyHours) {
|
||||||
|
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel title="Save & restart schedule">
|
||||||
|
<Rows items={items} />
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Rules() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.ruleset())
|
||||||
|
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
|
||||||
|
|
||||||
|
// The newest world.ruleset on the feed wins outright over the fetched copy —
|
||||||
|
// the frame is a complete ruleset, not a delta.
|
||||||
|
const ruleset = useMemo(() => events[0] || data || null, [data, events])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Live"
|
||||||
|
title="Shard ruleset"
|
||||||
|
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||||
|
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the shard ruleset right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && !ruleset && (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>
|
||||||
|
The shard has not published its ruleset yet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && ruleset && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Panel title="Shard">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Name', ruleset.shard || null],
|
||||||
|
['Expansion', ruleset.expansion || null],
|
||||||
|
['Connect', ruleset.connect || null],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{ruleset.systems && <Systems systems={ruleset.systems} />}
|
||||||
|
{ruleset.caps && <Caps caps={ruleset.caps} />}
|
||||||
|
<AccountsAndHousing
|
||||||
|
accounts={ruleset.accounts}
|
||||||
|
housing={ruleset.housing}
|
||||||
|
vetRewards={ruleset.vetRewards}
|
||||||
|
/>
|
||||||
|
{ruleset.champions && <Champions champions={ruleset.champions} />}
|
||||||
|
{ruleset.loot && <Felucca loot={ruleset.loot} />}
|
||||||
|
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
|
||||||
|
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
|
||||||
|
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,15 @@ import { describe } from '../../lib/shardEvents.js'
|
|||||||
import { ago } from '../../lib/format.js'
|
import { ago } from '../../lib/format.js'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
// Flavor line under the online/offline banner: online, configured-but-down, or
|
||||||
|
// not configured yet.
|
||||||
|
function statusMessage(online, enabled) {
|
||||||
|
if (online) return 'The gate to Britannia stands open.'
|
||||||
|
if (enabled) return 'The link to the game world is down — checking back automatically.'
|
||||||
|
return 'Live shard data is not configured yet.'
|
||||||
|
}
|
||||||
|
|
||||||
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||||
function Sparkline({ series }) {
|
function Sparkline({ series }) {
|
||||||
@@ -54,6 +63,11 @@ export default function Shard() {
|
|||||||
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||||
)
|
)
|
||||||
const { events, connected } = useShardFeed({ max: 30 })
|
const { events, connected } = useShardFeed({ max: 30 })
|
||||||
|
const { user } = useAuth()
|
||||||
|
// Staff in-game location is privileged: only admins/moderators see it. Players
|
||||||
|
// and the public see that staff are online but not where. The server enforces
|
||||||
|
// this too (it omits the location fields entirely for non-privileged callers).
|
||||||
|
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
|
||||||
|
|
||||||
const status = data?.status
|
const status = data?.status
|
||||||
const online = status?.pluginConnected
|
const online = status?.pluginConnected
|
||||||
@@ -69,44 +83,7 @@ export default function Shard() {
|
|||||||
|
|
||||||
{!loading && !error && data && (
|
{!loading && !error && data && (
|
||||||
<>
|
<>
|
||||||
{/* Connection banner */}
|
<ConnectionBanner online={online} status={status} />
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 16,
|
|
||||||
padding: '24px 26px',
|
|
||||||
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
|
||||||
borderRadius: 10,
|
|
||||||
background: online
|
|
||||||
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
|
||||||
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
flex: 'none',
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
|
||||||
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
|
||||||
{online ? 'The shard is online' : 'The shard is offline'}
|
|
||||||
</strong>
|
|
||||||
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
|
||||||
{online
|
|
||||||
? 'The gate to Britannia stands open.'
|
|
||||||
: status?.enabled
|
|
||||||
? 'The link to the game world is down — checking back automatically.'
|
|
||||||
: 'Live shard data is not configured yet.'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Stat tiles */}
|
{/* Stat tiles */}
|
||||||
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
||||||
@@ -119,29 +96,7 @@ export default function Shard() {
|
|||||||
<PlayersOnline />
|
<PlayersOnline />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Staff online — linked staff accounts only, with location */}
|
<StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
|
||||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
|
||||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
|
||||||
Staff online
|
|
||||||
</div>
|
|
||||||
{(!data.online || data.online.length === 0) ? (
|
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{data.online.map((p) => (
|
|
||||||
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
||||||
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
|
||||||
{p.name || p.serial}
|
|
||||||
</span>
|
|
||||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
|
||||||
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Economy sparkline */}
|
{/* Economy sparkline */}
|
||||||
{data.economy && data.economy.length > 1 && (
|
{data.economy && data.economy.length > 1 && (
|
||||||
@@ -158,11 +113,14 @@ export default function Shard() {
|
|||||||
<FeedList
|
<FeedList
|
||||||
title="Houses in danger (IDOC)"
|
title="Houses in danger (IDOC)"
|
||||||
empty="No houses are collapsing right now."
|
empty="No houses are collapsing right now."
|
||||||
items={data.idoc.map((h) => ({
|
items={data.idoc.map((h) => {
|
||||||
|
const region = h.region ? ` — ${h.region}` : ''
|
||||||
|
return {
|
||||||
id: h.serial,
|
id: h.serial,
|
||||||
text: `${h.name || 'A house'}${h.region ? ` — ${h.region}` : ''}`,
|
text: `${h.name || 'A house'}${region}`,
|
||||||
when: h.updatedAt,
|
when: h.updatedAt,
|
||||||
}))}
|
}
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,6 +162,75 @@ export default function Shard() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Online/offline banner with the flavor line under it.
|
||||||
|
function ConnectionBanner({ online, status }) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
padding: '24px 26px',
|
||||||
|
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: online
|
||||||
|
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||||
|
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||||
|
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
||||||
|
{online ? 'The shard is online' : 'The shard is offline'}
|
||||||
|
</strong>
|
||||||
|
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||||
|
{statusMessage(online, status?.enabled)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked staff accounts currently online; in-game location is admin/mod-only.
|
||||||
|
function StaffOnline({ list, canSeeLocation }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
Staff online
|
||||||
|
</div>
|
||||||
|
{(!list || list.length === 0) ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{list.map((p) => (
|
||||||
|
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||||
|
{p.name || p.serial}
|
||||||
|
</span>
|
||||||
|
{canSeeLocation && (
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||||
|
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FeedList({ title, items, empty }) {
|
function FeedList({ title, items, empty }) {
|
||||||
return (
|
return (
|
||||||
<section className="panel" style={{ padding: 20 }}>
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ export default function Wiki() {
|
|||||||
const activeTag = searchParams.get('tag')
|
const activeTag = searchParams.get('tag')
|
||||||
const activeQ = searchParams.get('q')
|
const activeQ = searchParams.get('q')
|
||||||
// Search / tag views fetch a filtered page list; otherwise all pages (grouped here).
|
// Search / tag views fetch a filtered page list; otherwise all pages (grouped here).
|
||||||
const pageOpts = activeQ ? { q: activeQ } : activeTag ? { tag: activeTag } : {}
|
let pageOpts = {}
|
||||||
|
if (activeQ) pageOpts = { q: activeQ }
|
||||||
|
else if (activeTag) pageOpts = { tag: activeTag }
|
||||||
const { loading, error, data } = useAsync(
|
const { loading, error, data } = useAsync(
|
||||||
() =>
|
() =>
|
||||||
Promise.all([api.wikiCategories(), api.wiki(pageOpts)]).then(([categories, pages]) => ({
|
Promise.all([api.wikiCategories(), api.wiki(pageOpts)]).then(([categories, pages]) => ({
|
||||||
|
|||||||
183
client/test/apiClient.test.js
Normal file
183
client/test/apiClient.test.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { test, beforeEach, afterEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { api, ApiError } from '../src/api/client.js'
|
||||||
|
|
||||||
|
// Unit-test the fetch wrapper that every API call flows through. The behaviors
|
||||||
|
// that matter to the whole app:
|
||||||
|
// - it always sends the session cookie (credentials: 'include');
|
||||||
|
// - a non-2xx response becomes a thrown ApiError carrying status + a message
|
||||||
|
// (server body.message → statusText → generic), never a silent bad value;
|
||||||
|
// - an empty body resolves to null (not a JSON parse throw);
|
||||||
|
// - JSON bodies get a Content-Type, but a raw FormData upload does NOT (so the
|
||||||
|
// browser can set the multipart boundary);
|
||||||
|
// - query strings and path params are built/encoded correctly.
|
||||||
|
// We drive the real req() by mocking global.fetch and inspecting what it received.
|
||||||
|
|
||||||
|
let calls
|
||||||
|
const realFetch = global.fetch
|
||||||
|
|
||||||
|
// Build a fake Response-ish object req() understands (ok/status/statusText/text()).
|
||||||
|
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
statusText,
|
||||||
|
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = []
|
||||||
|
global.fetch = async (url, opts) => {
|
||||||
|
calls.push({ url, opts })
|
||||||
|
return calls.nextReply || reply({ body: { ok: true } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = realFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
// helper to queue the next response
|
||||||
|
function willReply(r) {
|
||||||
|
global.fetch = async (url, opts) => {
|
||||||
|
calls.push({ url, opts })
|
||||||
|
return reply(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── happy path + cookie + base path ─────────────────────────────────────
|
||||||
|
test('a GET hits the same-origin /api/v1 base, sends cookies, and returns parsed JSON', async () => {
|
||||||
|
willReply({ body: { user: { id: 1 } } })
|
||||||
|
const out = await api.me()
|
||||||
|
assert.equal(calls[0].url, '/api/v1/auth/me')
|
||||||
|
assert.equal(calls[0].opts.credentials, 'include')
|
||||||
|
assert.equal(calls[0].opts.method, 'GET')
|
||||||
|
assert.deepEqual(out, { user: { id: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── error mapping ───────────────────────────────────────────────────────
|
||||||
|
test('a non-ok response throws an ApiError with status and the server message', async () => {
|
||||||
|
willReply({ status: 401, statusText: 'Unauthorized', body: { message: 'Incorrect username or password.' } })
|
||||||
|
await assert.rejects(
|
||||||
|
() => api.login('u', 'bad'),
|
||||||
|
(err) => {
|
||||||
|
assert.ok(err instanceof ApiError)
|
||||||
|
assert.equal(err.status, 401)
|
||||||
|
assert.equal(err.message, 'Incorrect username or password.')
|
||||||
|
assert.deepEqual(err.body, { message: 'Incorrect username or password.' })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an error with no JSON message falls back to statusText', async () => {
|
||||||
|
willReply({ status: 503, statusText: 'Service Unavailable', body: '' })
|
||||||
|
await assert.rejects(
|
||||||
|
() => api.status(),
|
||||||
|
(err) => err instanceof ApiError && err.status === 503 && err.message === 'Service Unavailable',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── empty body ──────────────────────────────────────────────────────────
|
||||||
|
test('an empty 200 body resolves to null instead of throwing on JSON.parse', async () => {
|
||||||
|
willReply({ status: 200, body: '' })
|
||||||
|
const out = await api.logout()
|
||||||
|
assert.equal(out, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a non-JSON body is returned as the raw text (safeParse tolerates it)', async () => {
|
||||||
|
willReply({ status: 200, body: 'plain text' })
|
||||||
|
const out = await api.me()
|
||||||
|
assert.equal(out, 'plain text')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── request body encoding ───────────────────────────────────────────────
|
||||||
|
test('a JSON POST serializes the body and sets Content-Type', async () => {
|
||||||
|
willReply({ body: { user: { id: 9 } } })
|
||||||
|
await api.register('newbie', 'pw', { company: '' })
|
||||||
|
const { opts } = calls[0]
|
||||||
|
assert.equal(opts.method, 'POST')
|
||||||
|
assert.equal(opts.headers['Content-Type'], 'application/json')
|
||||||
|
assert.deepEqual(JSON.parse(opts.body), { username: 'newbie', password: 'pw', company: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a raw FormData upload does NOT set Content-Type and passes the body untouched', async () => {
|
||||||
|
willReply({ body: { url: '/uploads/x.png' } })
|
||||||
|
const fakeFile = { name: 'x.png' }
|
||||||
|
await api.admin.upload(fakeFile)
|
||||||
|
const { opts } = calls[0]
|
||||||
|
assert.equal(opts.method, 'POST')
|
||||||
|
assert.equal(opts.headers['Content-Type'], undefined) // browser sets the multipart boundary
|
||||||
|
assert.ok(opts.body instanceof FormData)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── query strings + path param encoding ─────────────────────────────────
|
||||||
|
test('wiki() builds a query string only from the params that are set', async () => {
|
||||||
|
willReply({ body: [] })
|
||||||
|
await api.wiki({ category: 'lore', q: 'dragon slayer' })
|
||||||
|
const url = new URL(calls[0].url, 'http://x')
|
||||||
|
assert.equal(url.pathname, '/api/v1/public/wiki')
|
||||||
|
assert.equal(url.searchParams.get('category'), 'lore')
|
||||||
|
assert.equal(url.searchParams.get('q'), 'dragon slayer')
|
||||||
|
assert.equal(url.searchParams.get('tag'), null) // omitted when unset
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wiki() with no options sends no query string at all', async () => {
|
||||||
|
willReply({ body: [] })
|
||||||
|
await api.wiki()
|
||||||
|
assert.equal(calls[0].url, '/api/v1/public/wiki')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('path params are URL-encoded (a token/city with unsafe characters is escaped)', async () => {
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.shard.governorHistory('Serpent’s Hold', 5)
|
||||||
|
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.revokeMySession('a b/c')
|
||||||
|
assert.equal(calls[0].opts.method, 'DELETE')
|
||||||
|
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
|
||||||
|
// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard
|
||||||
|
// content parsed from the shard's own files, so it must not look sidecar-backed.
|
||||||
|
// Asserted here because the split is a design decision, not an accident of
|
||||||
|
// spelling.
|
||||||
|
test('atlas reads hit /public/atlas, not /public/shard', async () => {
|
||||||
|
willReply({ body: { creatures: [] } })
|
||||||
|
await api.atlas.creatures()
|
||||||
|
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('atlas.creatures() sends only the filters that are set', async () => {
|
||||||
|
willReply({ body: { creatures: [] } })
|
||||||
|
await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
|
||||||
|
const url = new URL(calls[0].url, 'http://x')
|
||||||
|
assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
|
||||||
|
assert.equal(url.searchParams.get('q'), 'lizard man')
|
||||||
|
assert.equal(url.searchParams.get('facet'), 'Ter Mur')
|
||||||
|
assert.equal(url.searchParams.get('limit'), '25')
|
||||||
|
assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
|
||||||
|
})
|
||||||
|
|
||||||
|
test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.atlas.creature('lizardman/rare', { facet: 'Felucca' })
|
||||||
|
assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('admin atlas actions use the right methods and bodies', async () => {
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.admin.atlas.import(true)
|
||||||
|
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
|
||||||
|
assert.equal(calls[0].opts.method, 'POST')
|
||||||
|
assert.equal(calls[0].opts.body, JSON.stringify({ force: true }))
|
||||||
|
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.admin.atlas.setPath('/srv/servuo')
|
||||||
|
assert.equal(calls[1].opts.method, 'PUT')
|
||||||
|
assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' }))
|
||||||
|
})
|
||||||
55
client/test/format.test.js
Normal file
55
client/test/format.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { longDate, shortDate, dateTime, monthTile, ago, categoryLabel } from '../src/lib/format.js'
|
||||||
|
|
||||||
|
// Unit-test the shared date/label formatters. Date strings are given with an
|
||||||
|
// explicit local time (no trailing Z) so getMonth/getDate read the same value in
|
||||||
|
// any timezone the test runs in — otherwise a date-only UTC string could shift a
|
||||||
|
// day. The point is to lock the human-facing formats the whole site renders.
|
||||||
|
|
||||||
|
test('longDate renders "Month D, YYYY"', () => {
|
||||||
|
assert.equal(longDate('2026-06-24T12:00:00'), 'June 24, 2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shortDate renders "Mon D"', () => {
|
||||||
|
assert.equal(shortDate('2026-06-24T12:00:00'), 'Jun 24')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dateTime renders "Mon D HH:MM" with zero-padded time', () => {
|
||||||
|
assert.equal(dateTime('2026-06-24T08:05:00'), 'Jun 24 08:05')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('monthTile returns the uppercased 3-letter month and 2-digit year', () => {
|
||||||
|
assert.deepEqual(monthTile('2026-06-24T12:00:00'), { mon: 'JUN', num: "'26" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every formatter returns an empty/placeholder value for a missing or invalid date', () => {
|
||||||
|
for (const bad of [null, undefined, '', 'not-a-date']) {
|
||||||
|
assert.equal(longDate(bad), '')
|
||||||
|
assert.equal(shortDate(bad), '')
|
||||||
|
assert.equal(dateTime(bad), '')
|
||||||
|
assert.equal(ago(bad), '')
|
||||||
|
assert.deepEqual(monthTile(bad), { mon: '—', num: '' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── ago(): relative-time buckets ────────────────────────────────────────
|
||||||
|
test('ago picks the right unit as the gap widens', () => {
|
||||||
|
const now = Date.now()
|
||||||
|
assert.equal(ago(new Date(now - 5 * 1000)), '5s ago')
|
||||||
|
assert.equal(ago(new Date(now - 5 * 60 * 1000)), '5m ago')
|
||||||
|
assert.equal(ago(new Date(now - 3 * 60 * 60 * 1000)), '3h ago')
|
||||||
|
assert.equal(ago(new Date(now - 2 * 24 * 60 * 60 * 1000)), '2d ago')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ago floors to at least 1s (never "0s ago" or a negative)', () => {
|
||||||
|
assert.equal(ago(new Date(Date.now())), '1s ago')
|
||||||
|
assert.equal(ago(new Date(Date.now() + 5000)), '1s ago') // a slightly-future timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── categoryLabel(): known map + passthrough ────────────────────────────
|
||||||
|
test('categoryLabel maps known db categories and passes unknown ones through', () => {
|
||||||
|
assert.equal(categoryLabel('five_on_friday'), 'Five on Friday')
|
||||||
|
assert.equal(categoryLabel('newsletter'), 'Newsletter')
|
||||||
|
assert.equal(categoryLabel('mystery_category'), 'mystery_category') // unknown → itself
|
||||||
|
})
|
||||||
81
client/test/heroLayout.test.js
Normal file
81
client/test/heroLayout.test.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
heroBgStack,
|
||||||
|
buildOverlay,
|
||||||
|
heroBackground,
|
||||||
|
parseLayout,
|
||||||
|
defaultLayout,
|
||||||
|
DEFAULT_HERO_IMAGE,
|
||||||
|
} from '../src/lib/heroLayout.js'
|
||||||
|
|
||||||
|
// Unit-test the hero-layout helpers shared by the public portal and the admin
|
||||||
|
// editor. The high-value logic: parseLayout must reject anything malformed or of
|
||||||
|
// the wrong version (a bad stored value must never render as a broken hero), and
|
||||||
|
// heroBackground must take the untouched-default branch only when there is no
|
||||||
|
// custom image.
|
||||||
|
|
||||||
|
// ── parseLayout: the version/shape guard ────────────────────────────────
|
||||||
|
test('parseLayout accepts a well-formed v1 layout', () => {
|
||||||
|
const layout = { version: 1, elements: [] }
|
||||||
|
assert.deepEqual(parseLayout(JSON.stringify(layout)), layout)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseLayout returns null for missing, malformed, wrong-version, or wrong-shape input', () => {
|
||||||
|
assert.equal(parseLayout(null), null)
|
||||||
|
assert.equal(parseLayout(''), null)
|
||||||
|
assert.equal(parseLayout('{ not json'), null)
|
||||||
|
assert.equal(parseLayout(JSON.stringify({ version: 2, elements: [] })), null) // wrong version
|
||||||
|
assert.equal(parseLayout(JSON.stringify({ version: 1, elements: 'nope' })), null) // elements not an array
|
||||||
|
assert.equal(parseLayout(JSON.stringify({ version: 1 })), null) // no elements
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── heroBackground: default vs custom branch ────────────────────────────
|
||||||
|
test('heroBackground uses the contained-emblem default stack only when default AND no custom image', () => {
|
||||||
|
const style = heroBackground({ background: {} }, { isDefault: true })
|
||||||
|
assert.match(style.backgroundImage, /runic-emblem\.png/)
|
||||||
|
assert.equal(style.backgroundSize, 'cover, min(74vh, 640px, 86vw)') // the two-layer default size
|
||||||
|
})
|
||||||
|
|
||||||
|
test('heroBackground threads a per-instance defaultImage into the default stack', () => {
|
||||||
|
const style = heroBackground({ background: {} }, { isDefault: true, defaultImage: '/brand/hero.jpg' })
|
||||||
|
assert.match(style.backgroundImage, /\/brand\/hero\.jpg/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('heroBackground composes overlay + custom image once a custom image is set', () => {
|
||||||
|
const style = heroBackground(
|
||||||
|
{ background: { image_url: '/uploads/hero.png', position_x: 'right', position_y: 'top', size: 'contain' }, overlay: { opacity: 0.5 } },
|
||||||
|
{ isDefault: true }, // still leaves the default branch because a custom image_url is present
|
||||||
|
)
|
||||||
|
assert.match(style.backgroundImage, /\/uploads\/hero\.png/)
|
||||||
|
assert.match(style.backgroundImage, /rgba\(11,15,20,0\.5\)/) // overlay opacity threaded in
|
||||||
|
assert.equal(style.backgroundPosition, 'right top')
|
||||||
|
assert.equal(style.backgroundSize, 'contain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('heroBackground defaults the overlay opacity to 0.72 when unset', () => {
|
||||||
|
const style = heroBackground({ background: { image_url: '/x.png' } }, {})
|
||||||
|
assert.match(style.backgroundImage, /rgba\(11,15,20,0\.72\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── smaller helpers ─────────────────────────────────────────────────────
|
||||||
|
test('heroBgStack falls back to the default emblem when no image is given', () => {
|
||||||
|
assert.match(heroBgStack(), new RegExp(DEFAULT_HERO_IMAGE.replace(/[/.]/g, '\\$&')))
|
||||||
|
assert.match(heroBgStack('/custom.png'), /\/custom\.png/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildOverlay scales both gradient stops from the opacity', () => {
|
||||||
|
const overlay = buildOverlay(0.8)
|
||||||
|
assert.match(overlay, /rgba\(11,15,20,0.12\)/) // 0.8 * 0.15 top stop
|
||||||
|
assert.match(overlay, /rgba\(11,15,20,0.8\)/) // bottom stop
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── defaultLayout: the fallback hero ────────────────────────────────────
|
||||||
|
test('defaultLayout builds a valid v1 layout that threads the teaser and shard name', () => {
|
||||||
|
const layout = defaultLayout('Come play with us', 'My Shard')
|
||||||
|
assert.equal(parseLayout(JSON.stringify(layout)) !== null, true, 'the default is itself a valid layout')
|
||||||
|
assert.equal(layout.version, 1)
|
||||||
|
const textLines = layout.elements.find((e) => e.id === 'default-text').props.lines
|
||||||
|
assert.ok(textLines.some((l) => l.text === 'My Shard'), 'shard name rendered as the h1')
|
||||||
|
assert.ok(textLines.some((l) => l.text === 'Come play with us'), 'teaser threaded in')
|
||||||
|
})
|
||||||
60
client/test/regionBuckets.test.js
Normal file
60
client/test/regionBuckets.test.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
|
||||||
|
|
||||||
|
// Unit-test the presence.online region roll-up for the "Players Online" widget.
|
||||||
|
// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
|
||||||
|
// total — anything unmatched lands in Wilderness — so the widget can never show
|
||||||
|
// a sum that disagrees with the headline online count.
|
||||||
|
|
||||||
|
test('bucketize groups named regions into their buckets', () => {
|
||||||
|
const { rows, total } = bucketize({
|
||||||
|
'Britain': 4,
|
||||||
|
'Moonglow': 2,
|
||||||
|
'Despise': 3,
|
||||||
|
'Green Acres House 12': 1, // not a town/dungeon name → Housing
|
||||||
|
})
|
||||||
|
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||||
|
assert.equal(byId.britain, 4)
|
||||||
|
assert.equal(byId.towns, 2)
|
||||||
|
assert.equal(byId.dungeons, 3)
|
||||||
|
assert.equal(byId.housing, 1)
|
||||||
|
assert.equal(total, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
|
||||||
|
// The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
|
||||||
|
// region whose name starts with a town name is bucketed as Towns. Pinning this
|
||||||
|
// documents the ordering dependency for anyone retuning BUCKETS.
|
||||||
|
const { rows } = bucketize({ 'Trinsic House 12': 1 })
|
||||||
|
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||||
|
assert.equal(byId.towns, 1)
|
||||||
|
assert.equal(byId.housing, undefined) // empty bucket dropped
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
|
||||||
|
const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
|
||||||
|
const wilderness = rows.find((r) => r.id === 'wilderness')
|
||||||
|
assert.equal(wilderness.count, 7)
|
||||||
|
assert.equal(total, 7)
|
||||||
|
// The reconciliation guarantee: the buckets sum to the total, exactly.
|
||||||
|
assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
|
||||||
|
const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
|
||||||
|
assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
|
||||||
|
assert.deepEqual(bucketize({}), { rows: [], total: 0 })
|
||||||
|
assert.deepEqual(bucketize(), { rows: [], total: 0 })
|
||||||
|
const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
|
||||||
|
assert.equal(total, 3) // '3' → 3, 'oops' → 0
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the last bucket is the catch-all (its match accepts anything)', () => {
|
||||||
|
const last = BUCKETS[BUCKETS.length - 1]
|
||||||
|
assert.equal(last.id, 'wilderness')
|
||||||
|
assert.equal(last.match('literally anything'), true)
|
||||||
|
})
|
||||||
74
client/test/shardEvents.test.js
Normal file
74
client/test/shardEvents.test.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
|
||||||
|
|
||||||
|
// Unit-test the shared shard-event formatter — the single place that decides how
|
||||||
|
// each event kind reads and which filter category it belongs to. These strings
|
||||||
|
// are user-facing on the public Shard page, the Activity feed, and the admin
|
||||||
|
// live feed, so a regression here is visible everywhere at once.
|
||||||
|
|
||||||
|
// ── describe(): works on both stored (.payload) and live (top-level) frames ──
|
||||||
|
test('describe reads fields from .payload when present, else the top level', () => {
|
||||||
|
const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
|
||||||
|
const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
|
||||||
|
assert.equal(describe(stored), 'Ada completed “The Cavern”')
|
||||||
|
assert.equal(describe(live), 'Ada completed “The Cavern”')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe resolves an actor from name → acct → "Someone"', () => {
|
||||||
|
assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
|
||||||
|
assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
|
||||||
|
assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
|
||||||
|
assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
|
||||||
|
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
|
||||||
|
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe includes the killer only when present (optional clause)', () => {
|
||||||
|
assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
|
||||||
|
assert.equal(
|
||||||
|
describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
|
||||||
|
'Ada was slain by Orc',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe champ.update branches on status and boss state', () => {
|
||||||
|
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
|
||||||
|
assert.equal(
|
||||||
|
describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
|
||||||
|
'Rikktor is active — level 3',
|
||||||
|
)
|
||||||
|
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe falls back to the raw kind for an unknown event', () => {
|
||||||
|
assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── categoryOf(): membership + catch-all ────────────────────────────────
|
||||||
|
test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
|
||||||
|
assert.equal(categoryOf('player.death'), 'pvp')
|
||||||
|
assert.equal(categoryOf('skill.gain'), 'progress')
|
||||||
|
assert.equal(categoryOf('house.decay'), 'world')
|
||||||
|
assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
|
||||||
|
assert.equal(categoryOf('totally.unknown'), 'other')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
|
||||||
|
for (const cat of CATEGORIES) {
|
||||||
|
if (!cat.kinds) continue
|
||||||
|
for (const kind of cat.kinds) {
|
||||||
|
assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── kindLabel(): badge text ─────────────────────────────────────────────
|
||||||
|
test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
|
||||||
|
assert.equal(kindLabel('player.death'), 'player death')
|
||||||
|
assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
|
||||||
|
assert.equal(kindLabel(null), '')
|
||||||
|
})
|
||||||
@@ -15,5 +15,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
// Don't inject the inline module-preload polyfill script — modern browsers all
|
||||||
|
// support modulepreload, and an inline <script> would violate the server's
|
||||||
|
// `script-src 'self'` CSP (see server/src/app.js). Keeps builds inline-free.
|
||||||
|
modulePreload: { polyfill: false },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,6 +55,40 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
|
ntfy:
|
||||||
|
# Self-hosted UnifiedPush relay for the app's opt-in push notifications
|
||||||
|
# (docs/android/PLAN.md §11). Pinned upstream image — fits this file's
|
||||||
|
# pull-only, never-build model. All config is declarative (./ntfy/server.yml
|
||||||
|
# + the NTFY_BASE_URL override below), so bringing the stack up provisions a
|
||||||
|
# working relay with NO interactive steps (no `ntfy user add`, no accounts).
|
||||||
|
# The backend treats ntfy as an untrusted relay and publishes only
|
||||||
|
# content-free tickles, so anonymous read-write to unguessable topics is safe.
|
||||||
|
image: binwiederhier/ntfy:v2.11.0
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["serve"]
|
||||||
|
environment:
|
||||||
|
# Public URL devices reach it at (behind the reverse proxy). MUST match the
|
||||||
|
# origin of the endpoints the app registers — the backend's SSRF allow-set
|
||||||
|
# (NTFY_BASE_URL / NTFY_ALLOWED_ORIGINS on the app) is derived from it.
|
||||||
|
NTFY_BASE_URL: ${NTFY_BASE_URL:-https://ntfy.localhost}
|
||||||
|
volumes:
|
||||||
|
- ntfydata:/var/lib/ntfy
|
||||||
|
- ./ntfy/server.yml:/etc/ntfy/server.yml:ro
|
||||||
|
# Published so the PUBLIC reverse proxy (Pangolin) can forward the
|
||||||
|
# notification subdomain here. Pangolin lives OUTSIDE the compose network and
|
||||||
|
# reaches every service through a published host port — never by joining the
|
||||||
|
# internal network — exactly like `app` above (3000). So ntfy must publish a
|
||||||
|
# port too: the reverse proxy maps notify.<host> -> host:NTFY_HOST_PORT ->
|
||||||
|
# ntfy:80. Unlike INTERNAL_PORT / the bot, ntfy is DEVICE-facing, so it is
|
||||||
|
# SUPPOSED to be reachable through the proxy. Binds 0.0.0.0 (no 127.0.0.1
|
||||||
|
# prefix) so Pangolin can reach the container. Both the app (SSE subscribe) and
|
||||||
|
# the backend (POSTing content-free tickles to each device's registered
|
||||||
|
# endpoint) reach ntfy on this same public origin — NTFY_ALLOWED_ORIGINS pins
|
||||||
|
# it — so all ntfy traffic flows through the proxy; there is no separate
|
||||||
|
# internal publish port.
|
||||||
|
ports:
|
||||||
|
- "${NTFY_HOST_PORT:-2586}:80"
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
# Same as app: prebuilt bot image, pulled in production. Build locally via
|
# Same as app: prebuilt bot image, pulled in production. Build locally via
|
||||||
# docker-compose.dev.yml.
|
# docker-compose.dev.yml.
|
||||||
@@ -90,3 +124,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
uploads:
|
uploads:
|
||||||
|
ntfydata:
|
||||||
|
|||||||
35
ntfy/server.yml
Normal file
35
ntfy/server.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# ── ntfy self-hosted server config (UnifiedPush relay) ─────────────────────
|
||||||
|
#
|
||||||
|
# Backs the Android app's opt-in push notifications (docs/android/PLAN.md §11).
|
||||||
|
# Declarative + committed: `docker compose up` provisions a working relay with
|
||||||
|
# NO interactive setup — no `ntfy user add`, no per-user accounts, no post-deploy
|
||||||
|
# steps. The website backend treats ntfy as an UNTRUSTED relay and only ever
|
||||||
|
# publishes content-free tickles ({ stream, ref }); the real, ownership-checked
|
||||||
|
# content is pulled by the app over the authenticated website API. That is why
|
||||||
|
# anonymous access to unguessable topics is intentional and safe here.
|
||||||
|
#
|
||||||
|
# The public base URL is provided per-deploy via the NTFY_BASE_URL env var in
|
||||||
|
# docker-compose.yml (ntfy env vars override this file), so this default is only
|
||||||
|
# a placeholder for a bare `ntfy serve`.
|
||||||
|
base-url: "https://ntfy.localhost"
|
||||||
|
|
||||||
|
# ntfy listens on :80 inside the container. docker-compose.yml publishes this on
|
||||||
|
# a host port (NTFY_HOST_PORT, default 2586) so the public reverse proxy — which
|
||||||
|
# lives OUTSIDE the compose network — can terminate TLS and forward the
|
||||||
|
# notification subdomain to it. Both the app (SSE subscribe) and the backend
|
||||||
|
# (POSTing content-free tickles to registered device endpoints) reach ntfy on
|
||||||
|
# that public origin, so all traffic flows through the proxy.
|
||||||
|
listen-http: ":80"
|
||||||
|
behind-proxy: true
|
||||||
|
|
||||||
|
# Persist the message cache + (empty) auth db on the named volume.
|
||||||
|
cache-file: "/var/lib/ntfy/cache.db"
|
||||||
|
auth-file: "/var/lib/ntfy/auth.db"
|
||||||
|
|
||||||
|
# No accounts to administer — anonymous read+write to unguessable topics. Safe
|
||||||
|
# because payloads are content-free; the security boundary is the authenticated
|
||||||
|
# website API, not ntfy (see the header note).
|
||||||
|
auth-default-access: "read-write"
|
||||||
|
|
||||||
|
# Pure relay: no attachments.
|
||||||
|
attachment-cache-dir: ""
|
||||||
58
scripts/dev/README.md
Normal file
58
scripts/dev/README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Dev SSO tooling
|
||||||
|
|
||||||
|
Local-only helpers for exercising the native **mobile SSO bridge** without a real
|
||||||
|
OAuth provider. Dev environments have no IdP configured, so `GET /auth/providers`
|
||||||
|
returns `[]`, the app renders no SSO buttons, and the flow can't be tested. These
|
||||||
|
scripts stand up a stub IdP, register it, and verify the full bridge headlessly.
|
||||||
|
|
||||||
|
> **DEV ONLY.** `stub-idp.js` performs no credential checks and will sign in anyone.
|
||||||
|
> Never run it against a shared/production database or expose it publicly.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `stub-idp.js` | Dependency-free stub OAuth2/OIDC IdP: `GET /authorize` (account picker), `POST /token`, `GET /userinfo`. |
|
||||||
|
| `seed-sso-provider.js` | Registers an `auth_providers` row (`devstub`) pointing at the stub and pre-links each principal's `sub` to a dev account (SSO is link-only). Reads DB creds from `server/.env`. |
|
||||||
|
| `sso-bridge-smoketest.js` | Drives the whole app flow headless: PKCE → `/auth/mobile/sso/start` → stub → website callback → `runicgateway://auth/callback` deep link → `/auth/mobile/sso/exchange`. |
|
||||||
|
|
||||||
|
## Usage (host / headless)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. seed the provider + linked identities (one-time; idempotent)
|
||||||
|
node scripts/dev/seed-sso-provider.js
|
||||||
|
|
||||||
|
# 2. run the stub IdP (leave running)
|
||||||
|
node scripts/dev/stub-idp.js
|
||||||
|
|
||||||
|
# 3. run the website with the callback origin pointed at the API port, so the
|
||||||
|
# whole flow is same-origin (dev default APP_BASE_URL is the Vite client :5173)
|
||||||
|
cd server && APP_BASE_URL=http://127.0.0.1:3000 npm start
|
||||||
|
|
||||||
|
# 4. verify the bridge (from the website root)
|
||||||
|
node scripts/dev/sso-bridge-smoketest.js stub-colby # or stub-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
A pass prints the resolved user, an access token, and a present refresh token.
|
||||||
|
|
||||||
|
## Usage (Android emulator)
|
||||||
|
|
||||||
|
The **authorize** URL is followed by the device browser (Custom Tab); **token** and
|
||||||
|
**userinfo** are called server-side by the website. On an emulator the host is
|
||||||
|
`10.0.2.2`, so seed with split URLs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
STUB_IDP_PUBLIC_URL=http://10.0.2.2:9099 \
|
||||||
|
STUB_IDP_INTERNAL_URL=http://127.0.0.1:9099 \
|
||||||
|
node scripts/dev/seed-sso-provider.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the app's server at `http://10.0.2.2:3000`, and run the website with
|
||||||
|
`APP_BASE_URL=http://10.0.2.2:3000` so the IdP callback returns to a device-reachable
|
||||||
|
origin.
|
||||||
|
|
||||||
|
## Principals
|
||||||
|
|
||||||
|
`stub-admin` → dev user `admin` (role admin) · `stub-colby` → dev user `colby`
|
||||||
|
(role player). Keep the `sub` list in sync between `stub-idp.js` and
|
||||||
|
`seed-sso-provider.js`.
|
||||||
105
scripts/dev/seed-sso-provider.js
Normal file
105
scripts/dev/seed-sso-provider.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* Seed a dev SSO provider + pre-linked identities — DEV ONLY.
|
||||||
|
*
|
||||||
|
* Registers an `auth_providers` row pointing at the local `stub-idp.js`, so
|
||||||
|
* `GET /auth/providers` returns a provider and the native mobile SSO flow becomes
|
||||||
|
* exercisable. Because SSO is link-only (identities are never auto-provisioned),
|
||||||
|
* it also pre-links each stub principal's `sub` to an existing dev account.
|
||||||
|
*
|
||||||
|
* Idempotent: re-running upserts the provider and skips already-linked identities.
|
||||||
|
*
|
||||||
|
* Run (loads website/server/.env for DB creds):
|
||||||
|
* node website/scripts/dev/seed-sso-provider.js
|
||||||
|
*
|
||||||
|
* Env overrides:
|
||||||
|
* STUB_PROVIDER_ID provider slug (default 'devstub')
|
||||||
|
* STUB_IDP_PUBLIC_URL browser-facing base (default http://127.0.0.1:9099)
|
||||||
|
* STUB_IDP_INTERNAL_URL server-facing base (default = STUB_IDP_PUBLIC_URL)
|
||||||
|
*
|
||||||
|
* The authorize URL is followed by the browser (Custom Tab); token/userinfo are
|
||||||
|
* called server-side by the website. On an emulator set PUBLIC to the host's
|
||||||
|
* reachable address (e.g. http://10.0.2.2:9099) and INTERNAL to http://127.0.0.1:9099.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// Load website/server/.env into process.env WITHOUT the dotenv dependency (it
|
||||||
|
// lives in server/node_modules and wouldn't resolve from this scripts/ location).
|
||||||
|
// The file is simple KEY=value; that is all we need for the DB credentials.
|
||||||
|
function loadEnv(envPath) {
|
||||||
|
if (!fs.existsSync(envPath)) return
|
||||||
|
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
|
||||||
|
const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
|
||||||
|
if (!m) continue
|
||||||
|
const key = m[1]
|
||||||
|
let val = m[2].trim()
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1)
|
||||||
|
}
|
||||||
|
if (process.env[key] === undefined) process.env[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadEnv(path.join(__dirname, '..', '..', 'server', '.env'))
|
||||||
|
|
||||||
|
const authProviders = require('../../server/src/model/authProviders/authProviders.model')
|
||||||
|
const userIdentities = require('../../server/src/model/userIdentities/userIdentities.model')
|
||||||
|
const { close } = require('../../server/src/utils/db')
|
||||||
|
|
||||||
|
const PROVIDER_ID = process.env.STUB_PROVIDER_ID || 'devstub'
|
||||||
|
const PUBLIC_URL = (process.env.STUB_IDP_PUBLIC_URL || 'http://127.0.0.1:9099').replace(/\/$/, '')
|
||||||
|
const INTERNAL_URL = (process.env.STUB_IDP_INTERNAL_URL || PUBLIC_URL).replace(/\/$/, '')
|
||||||
|
|
||||||
|
// sub → dev account id. Keep the subs in sync with stub-idp.js PRINCIPALS.
|
||||||
|
const PRINCIPALS = [
|
||||||
|
{ sub: 'stub-admin', email: 'admin@dev.local', userId: 1 },
|
||||||
|
{ sub: 'stub-colby', email: 'colby@dev.local', userId: 14 },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
const log = (...a) => console.log('[seed-sso]', ...a)
|
||||||
|
|
||||||
|
await authProviders.save(PROVIDER_ID, {
|
||||||
|
kind: 'oauth2',
|
||||||
|
name: 'Dev Stub IdP',
|
||||||
|
enabled: true,
|
||||||
|
clientId: 'stub-client',
|
||||||
|
secret: 'stub-secret',
|
||||||
|
authorizeUrl: `${PUBLIC_URL}/authorize`,
|
||||||
|
tokenUrl: `${INTERNAL_URL}/token`,
|
||||||
|
userinfoUrl: `${INTERNAL_URL}/userinfo`,
|
||||||
|
scopes: 'openid email profile',
|
||||||
|
priority: 50,
|
||||||
|
})
|
||||||
|
log(`provider '${PROVIDER_ID}' upserted (authorize=${PUBLIC_URL}/authorize, token/userinfo=${INTERNAL_URL})`)
|
||||||
|
|
||||||
|
for (const p of PRINCIPALS) {
|
||||||
|
const existing = await userIdentities.findByProviderSubject(PROVIDER_ID, p.sub)
|
||||||
|
if (existing) {
|
||||||
|
log(`identity ${PROVIDER_ID}:${p.sub} already linked to user ${existing.user_id} — skip`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await userIdentities.link({ userId: p.userId, provider: PROVIDER_ID, subject: p.sub, email: p.email })
|
||||||
|
log(`linked ${PROVIDER_ID}:${p.sub} → user ${p.userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
log('done.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[seed-sso] FAILED', err)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
try {
|
||||||
|
await close()
|
||||||
|
} catch {
|
||||||
|
/* ignore shutdown errors */
|
||||||
|
}
|
||||||
|
})
|
||||||
109
scripts/dev/sso-bridge-smoketest.js
Normal file
109
scripts/dev/sso-bridge-smoketest.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* Mobile SSO bridge smoketest — DEV ONLY, pairs with stub-idp.js.
|
||||||
|
*
|
||||||
|
* Drives the full native-SSO flow the Android app performs, headless, so the
|
||||||
|
* website bridge can be verified without an emulator/IdP:
|
||||||
|
* 1. mint PKCE (Layer B) + state
|
||||||
|
* 2. GET /auth/mobile/sso/start → 302 to the stub /authorize
|
||||||
|
* 3. follow the stub picker (inject `login_as`) → 302 to the website callback
|
||||||
|
* 4. website callback exchanges the IdP code server-side, resolves the linked
|
||||||
|
* user, and 302s to the app deep link runicgateway://auth/callback?code&state
|
||||||
|
* 5. POST /auth/mobile/sso/exchange { code, code_verifier } → the bearer pair
|
||||||
|
*
|
||||||
|
* Prereqs: stub-idp.js running, seed-sso-provider.js applied, website on :3000.
|
||||||
|
*
|
||||||
|
* Run: node website/scripts/dev/sso-bridge-smoketest.js [stub-colby|stub-admin]
|
||||||
|
* Env: BASE_URL (default http://127.0.0.1:3000), REDIRECT_URI
|
||||||
|
* (default runicgateway://auth/callback)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
const BASE = (process.env.BASE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '')
|
||||||
|
const REDIRECT = process.env.REDIRECT_URI || 'runicgateway://auth/callback'
|
||||||
|
const PROVIDER = process.env.PROVIDER_ID || 'devstub'
|
||||||
|
const LOGIN_AS = process.argv[2] || 'stub-colby'
|
||||||
|
|
||||||
|
const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
|
||||||
|
// A minimal cookie jar: name → value, updated from every Set-Cookie.
|
||||||
|
const jar = {}
|
||||||
|
function storeCookies(res) {
|
||||||
|
const raw = res.headers.getSetCookie ? res.headers.getSetCookie() : res.headers.raw?.()['set-cookie'] || []
|
||||||
|
for (const c of raw) {
|
||||||
|
const [pair] = c.split(';')
|
||||||
|
const idx = pair.indexOf('=')
|
||||||
|
if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cookieHeader = () =>
|
||||||
|
Object.entries(jar)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('; ')
|
||||||
|
|
||||||
|
async function step(name, url, opts = {}) {
|
||||||
|
const res = await fetch(url, { redirect: 'manual', headers: { Cookie: cookieHeader(), ...(opts.headers || {}) }, ...opts })
|
||||||
|
storeCookies(res)
|
||||||
|
const loc = res.headers.get('location')
|
||||||
|
console.log(`\n[${name}] ${res.status} ${url.split('?')[0]}`)
|
||||||
|
if (loc) console.log(` → Location: ${loc}`)
|
||||||
|
return { res, loc }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const verifier = b64url(crypto.randomBytes(32))
|
||||||
|
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest())
|
||||||
|
const state = b64url(crypto.randomBytes(16))
|
||||||
|
console.log(`PKCE verifier=${verifier.slice(0, 12)}… challenge=${challenge.slice(0, 12)}… state=${state.slice(0, 12)}… loginAs=${LOGIN_AS}`)
|
||||||
|
|
||||||
|
// 2. start → 302 to stub /authorize
|
||||||
|
const startUrl =
|
||||||
|
`${BASE}/api/v1/auth/mobile/sso/start?provider=${PROVIDER}` +
|
||||||
|
`&code_challenge=${encodeURIComponent(challenge)}&state=${encodeURIComponent(state)}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(REDIRECT)}`
|
||||||
|
let { loc } = await step('start', startUrl)
|
||||||
|
if (!loc || !loc.includes('/authorize')) throw new Error('start did not redirect to the IdP authorize endpoint')
|
||||||
|
|
||||||
|
// 3. stub authorize: inject the account choice the picker would make.
|
||||||
|
const authUrl = new URL(loc)
|
||||||
|
authUrl.searchParams.set('login_as', LOGIN_AS)
|
||||||
|
;({ loc } = await step('idp-authorize', authUrl.toString()))
|
||||||
|
if (!loc || !loc.includes('/sso/')) throw new Error('IdP did not redirect back to the website callback')
|
||||||
|
|
||||||
|
// 4. website callback: server-side token+userinfo, resolve user, deep-link back.
|
||||||
|
;({ loc } = await step('callback', loc))
|
||||||
|
if (!loc) throw new Error('callback produced no redirect')
|
||||||
|
const deep = new URL(loc)
|
||||||
|
const appCode = deep.searchParams.get('code')
|
||||||
|
const appState = deep.searchParams.get('state')
|
||||||
|
const appErr = deep.searchParams.get('error')
|
||||||
|
if (appErr) throw new Error(`callback returned error to app: ${appErr}`)
|
||||||
|
if (!appCode) throw new Error(`callback did not deep-link a code (got ${loc})`)
|
||||||
|
if (appState !== state) throw new Error(`state mismatch: sent ${state}, got ${appState}`)
|
||||||
|
console.log(` ✓ deep link carries code=${appCode.slice(0, 10)}… state matches`)
|
||||||
|
|
||||||
|
// 5. exchange the app code + PKCE verifier for the bearer pair.
|
||||||
|
const exRes = await fetch(`${BASE}/api/v1/auth/mobile/sso/exchange`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: appCode, code_verifier: verifier, device_name: 'sso-smoketest' }),
|
||||||
|
})
|
||||||
|
const exBody = await exRes.json().catch(() => ({}))
|
||||||
|
console.log(`\n[exchange] ${exRes.status}`)
|
||||||
|
if (!exRes.ok) throw new Error(`exchange failed: ${exRes.status} ${JSON.stringify(exBody)}`)
|
||||||
|
const hasPair = exBody.accessToken && exBody.refreshToken
|
||||||
|
console.log(` user: ${JSON.stringify(exBody.user)}`)
|
||||||
|
console.log(` accessToken: ${exBody.accessToken ? exBody.accessToken.slice(0, 16) + '…' : '(none)'}`)
|
||||||
|
console.log(` refreshToken: ${exBody.refreshToken ? '(present)' : '(none)'}`)
|
||||||
|
if (!hasPair) throw new Error('exchange did not return an access/refresh pair')
|
||||||
|
|
||||||
|
console.log('\n✅ SSO bridge smoketest PASSED')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('\n❌ SSO bridge smoketest FAILED:', err.message)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
175
scripts/dev/stub-idp.js
Normal file
175
scripts/dev/stub-idp.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* Stub OAuth2 / OIDC IdP — DEV ONLY.
|
||||||
|
*
|
||||||
|
* Dev environments have no real OAuth provider configured, so `GET /auth/providers`
|
||||||
|
* returns `[]` and the native mobile SSO flow can never be exercised. This tiny,
|
||||||
|
* dependency-free IdP stands in for Google/Discord/a custom OIDC so the full bridge
|
||||||
|
* (app → website `/auth/mobile/sso/start` → IdP → callback → `/exchange`) can be
|
||||||
|
* driven end-to-end against the local site. It mirrors the throwaway-stub precedent
|
||||||
|
* in `servuo-plugins/tools/stub_sidecar.ps1`.
|
||||||
|
*
|
||||||
|
* It implements the three endpoints `oauth2.provider.js` calls:
|
||||||
|
* GET /authorize → account picker, then 302 to redirect_uri?code&state
|
||||||
|
* POST /token → { access_token, token_type, expires_in }
|
||||||
|
* GET /userinfo → { sub, email, name } (Bearer <access_token>)
|
||||||
|
*
|
||||||
|
* Pair it with `seed-sso-provider.js`, which registers a matching `auth_providers`
|
||||||
|
* row and pre-links each principal's `sub` to a dev account (SSO is link-only).
|
||||||
|
*
|
||||||
|
* Run: node website/scripts/dev/stub-idp.js
|
||||||
|
* Env: STUB_IDP_PORT (default 9099), STUB_IDP_HOST (default 127.0.0.1)
|
||||||
|
*
|
||||||
|
* NEVER deploy this. It performs no credential checks and signs in anyone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const http = require('http')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const { URL, URLSearchParams } = require('url')
|
||||||
|
|
||||||
|
const PORT = Number(process.env.STUB_IDP_PORT || 9099)
|
||||||
|
const HOST = process.env.STUB_IDP_HOST || '127.0.0.1'
|
||||||
|
|
||||||
|
// Test principals the picker offers. Each `sub` must be pre-linked to a real dev
|
||||||
|
// account by seed-sso-provider.js, or the link-only login will reject it. Keep
|
||||||
|
// this list in sync with that script's PRINCIPALS.
|
||||||
|
const PRINCIPALS = [
|
||||||
|
{ sub: 'stub-admin', email: 'admin@dev.local', name: 'Dev Admin' },
|
||||||
|
{ sub: 'stub-colby', email: 'colby@dev.local', name: 'Dev Colby' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Short-lived in-memory maps: auth code → principal, access token → principal.
|
||||||
|
// Codes are single-use; both are cleared on process exit (dev only).
|
||||||
|
const codes = new Map()
|
||||||
|
const tokens = new Map()
|
||||||
|
|
||||||
|
function log(...args) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[stub-idp ${new Date().toISOString()}]`, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerPage(query) {
|
||||||
|
const rows = PRINCIPALS.map((p) => {
|
||||||
|
const q = new URLSearchParams(query)
|
||||||
|
q.set('login_as', p.sub)
|
||||||
|
return `<li><a href="/authorize?${q.toString()}">${p.name} <${p.email}> <code>${p.sub}</code></a></li>`
|
||||||
|
}).join('\n')
|
||||||
|
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Stub IdP</title>
|
||||||
|
<style>body{font:16px system-ui;margin:3rem auto;max-width:34rem}a{display:block;padding:.6rem;border:1px solid #ccc;border-radius:8px;margin:.4rem 0;text-decoration:none;color:#123}code{color:#888}</style>
|
||||||
|
<h1>Stub IdP — choose a test account</h1>
|
||||||
|
<p>DEV ONLY. Signs you in as the selected pre-linked identity.</p>
|
||||||
|
<ul style="list-style:none;padding:0">${rows}</ul>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, status, obj) {
|
||||||
|
const body = JSON.stringify(obj)
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) })
|
||||||
|
res.end(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthorize(req, res, url) {
|
||||||
|
const params = url.searchParams
|
||||||
|
const redirectUri = params.get('redirect_uri')
|
||||||
|
const state = params.get('state') || ''
|
||||||
|
const loginAs = params.get('login_as')
|
||||||
|
|
||||||
|
if (!redirectUri) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
||||||
|
return res.end('missing redirect_uri')
|
||||||
|
}
|
||||||
|
|
||||||
|
// No account chosen yet → show the picker (preserving the OAuth query params).
|
||||||
|
if (!loginAs) {
|
||||||
|
const html = pickerPage(params)
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||||
|
return res.end(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
const principal = PRINCIPALS.find((p) => p.sub === loginAs)
|
||||||
|
if (!principal) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
||||||
|
return res.end(`unknown principal '${loginAs}'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a single-use authorization code bound to the principal and redirect back.
|
||||||
|
const code = crypto.randomBytes(24).toString('hex')
|
||||||
|
codes.set(code, principal)
|
||||||
|
const back = new URL(redirectUri)
|
||||||
|
back.searchParams.set('code', code)
|
||||||
|
if (state) back.searchParams.set('state', state)
|
||||||
|
log('authorize → issuing code for', principal.sub, '→', back.toString())
|
||||||
|
res.writeHead(302, { Location: back.toString() })
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let data = ''
|
||||||
|
req.on('data', (c) => {
|
||||||
|
data += c
|
||||||
|
})
|
||||||
|
req.on('end', () => resolve(data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToken(req, res) {
|
||||||
|
const raw = await readBody(req)
|
||||||
|
const body = new URLSearchParams(raw)
|
||||||
|
const code = body.get('code')
|
||||||
|
const principal = code && codes.get(code)
|
||||||
|
if (!principal) {
|
||||||
|
log('token → invalid/expired code', code)
|
||||||
|
return sendJson(res, 400, { error: 'invalid_grant' })
|
||||||
|
}
|
||||||
|
codes.delete(code) // single-use
|
||||||
|
const accessToken = crypto.randomBytes(24).toString('hex')
|
||||||
|
tokens.set(accessToken, principal)
|
||||||
|
log('token → access token for', principal.sub)
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
access_token: accessToken,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: body.get('scope') || 'openid email profile',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserinfo(req, res) {
|
||||||
|
const auth = req.headers.authorization || ''
|
||||||
|
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null
|
||||||
|
const principal = token && tokens.get(token)
|
||||||
|
if (!principal) {
|
||||||
|
log('userinfo → missing/invalid bearer')
|
||||||
|
return sendJson(res, 401, { error: 'invalid_token' })
|
||||||
|
}
|
||||||
|
log('userinfo → returning profile for', principal.sub)
|
||||||
|
return sendJson(res, 200, { sub: principal.sub, email: principal.email, name: principal.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`)
|
||||||
|
log(req.method, url.pathname)
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET' && url.pathname === '/authorize') return handleAuthorize(req, res, url)
|
||||||
|
if (req.method === 'POST' && url.pathname === '/token') return await handleToken(req, res)
|
||||||
|
if (req.method === 'GET' && url.pathname === '/userinfo') return handleUserinfo(req, res)
|
||||||
|
if (req.method === 'GET' && url.pathname === '/') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
||||||
|
return res.end('stub-idp OK. Endpoints: GET /authorize, POST /token, GET /userinfo')
|
||||||
|
}
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
||||||
|
res.end('not found')
|
||||||
|
} catch (err) {
|
||||||
|
log('error', err)
|
||||||
|
sendJson(res, 500, { error: 'server_error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
log(`listening on http://${HOST}:${PORT}`)
|
||||||
|
log('authorize:', `http://${HOST}:${PORT}/authorize`)
|
||||||
|
log('token: ', `http://${HOST}:${PORT}/token`)
|
||||||
|
log('userinfo: ', `http://${HOST}:${PORT}/userinfo`)
|
||||||
|
log('principals:', PRINCIPALS.map((p) => p.sub).join(', '))
|
||||||
|
})
|
||||||
64
scripts/sonar-test-reporter.mjs
Normal file
64
scripts/sonar-test-reporter.mjs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Custom node:test reporter that emits SonarQube's Generic Test Execution XML.
|
||||||
|
//
|
||||||
|
// Node's built-in reporters give us coverage (`lcov`) and pass/fail output
|
||||||
|
// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a
|
||||||
|
// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the
|
||||||
|
// lcov report only populates Coverage, which is why the dashboard shows coverage
|
||||||
|
// while the Unit Tests tile stays "-". This reporter produces that missing report.
|
||||||
|
//
|
||||||
|
// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/
|
||||||
|
// <testExecutions version="1">
|
||||||
|
// <file path="server/test/foo.test.js">
|
||||||
|
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
|
||||||
|
// </file>
|
||||||
|
// </testExecutions>
|
||||||
|
//
|
||||||
|
// Paths are emitted repo-root-relative (POSIX separators) so they match the
|
||||||
|
// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the
|
||||||
|
// absolute `file` on each event strips cleanly against process.cwd().
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
function xmlEscape(s) {
|
||||||
|
return String(s).replace(/[<>&"']/g, (c) => ({
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'&': '&',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
})[c])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function* sonarTestReporter(source) {
|
||||||
|
const byFile = new Map()
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
|
for await (const event of source) {
|
||||||
|
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue
|
||||||
|
const d = event.data
|
||||||
|
// Skip the container events (a `describe` suite) and anything without a file
|
||||||
|
// — only real test cases go in the report, so the count matches the runner's.
|
||||||
|
if (!d.file || (d.details && d.details.type === 'suite')) continue
|
||||||
|
|
||||||
|
const rel = path.relative(cwd, d.file).split(path.sep).join('/')
|
||||||
|
if (!byFile.has(rel)) byFile.set(rel, [])
|
||||||
|
byFile.get(rel).push({
|
||||||
|
name: d.name,
|
||||||
|
duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)),
|
||||||
|
failed: event.type === 'test:fail',
|
||||||
|
skipped: Boolean(d.skip || d.todo),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
yield '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
|
||||||
|
for (const [file, cases] of byFile) {
|
||||||
|
yield ` <file path="${xmlEscape(file)}">\n`
|
||||||
|
for (const c of cases) {
|
||||||
|
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
|
||||||
|
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
|
||||||
|
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
|
||||||
|
else yield ` <testCase ${attrs}/>\n`
|
||||||
|
}
|
||||||
|
yield ' </file>\n'
|
||||||
|
}
|
||||||
|
yield '</testExecutions>\n'
|
||||||
|
}
|
||||||
@@ -27,6 +27,15 @@ JWT_EXPIRES_IN=1d
|
|||||||
COOKIE_SECURE=auto
|
COOKIE_SECURE=auto
|
||||||
COOKIE_NAME=rg_token
|
COOKIE_NAME=rg_token
|
||||||
|
|
||||||
|
# Trusted-device MFA ("Trust this device"). The trust cookie's name, how long a
|
||||||
|
# device stays trusted (skips the TOTP step, never the password), the per-user cap
|
||||||
|
# (no silent pruning — an over-cap trust is refused), and how many single-use
|
||||||
|
# recovery codes are generated at 2FA enrollment.
|
||||||
|
TRUST_COOKIE_NAME=rg_trust
|
||||||
|
TRUSTED_DEVICE_TTL_DAYS=30
|
||||||
|
MAX_TRUSTED_DEVICES=10
|
||||||
|
RECOVERY_CODE_COUNT=10
|
||||||
|
|
||||||
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
|
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
|
||||||
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
|
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
|
||||||
# insecure key is derived from JWT_SECRET if unset (with a warning).
|
# insecure key is derived from JWT_SECRET if unset (with a warning).
|
||||||
@@ -97,3 +106,24 @@ BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
|||||||
# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400)
|
# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400)
|
||||||
ANNOUNCE_POLL_MS=15000
|
ANNOUNCE_POLL_MS=15000
|
||||||
TOWNCRIER_DURATION_SEC=3600
|
TOWNCRIER_DURATION_SEC=3600
|
||||||
|
|
||||||
|
# Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted
|
||||||
|
# ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs
|
||||||
|
# content-free tickles to each device's endpoint, so no publish token is required.
|
||||||
|
# NTFY_BASE_URL Internal relay URL the publisher POSTs to; also part of the
|
||||||
|
# backend's SSRF allow-set — a device may only register an
|
||||||
|
# endpoint on an allowed origin.
|
||||||
|
# NTFY_PUBLIC_URL Client-facing relay URL surfaced to the app via
|
||||||
|
# /public/settings.push.ntfyUrl (the app registers its topic
|
||||||
|
# endpoint here). Defaults to the first NTFY_ALLOWED_ORIGINS
|
||||||
|
# entry; set when the public URL differs from NTFY_BASE_URL.
|
||||||
|
# NTFY_ALLOWED_ORIGINS Optional comma-separated allowed origins (the app's endpoint
|
||||||
|
# must sit on one). Also the default source for NTFY_PUBLIC_URL.
|
||||||
|
# NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default).
|
||||||
|
# Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint
|
||||||
|
# (private/loopback hosts are always rejected). Without NTFY_PUBLIC_URL /
|
||||||
|
# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for the shard.
|
||||||
|
# NTFY_BASE_URL=https://ntfy.example.com
|
||||||
|
# NTFY_PUBLIC_URL=https://ntfy.example.com
|
||||||
|
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
||||||
|
# NTFY_PUBLISH_TOKEN=
|
||||||
|
|||||||
24
server/db/data/spawnAtlas.art.example.json
Normal file
24
server/db/data/spawnAtlas.art.example.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"_comment": [
|
||||||
|
"OPTIONAL operator-supplied creature art for the spawn atlas. Copy this file to",
|
||||||
|
"spawnAtlas.art.json (same directory) and edit it, then restart the server or run",
|
||||||
|
"`npm run atlas:import` — the art map is read on every atlas refresh.",
|
||||||
|
"",
|
||||||
|
"This project ships NO creature artwork and never will. UO sprites live in your",
|
||||||
|
"own client's .mul/.uop files and are yours to extract, not ours to redistribute.",
|
||||||
|
"If you want art on the atlas pages, export it yourself (UOFiddler, ClassicUO's",
|
||||||
|
"tooling, or any art extractor), drop the images under server/uploads/atlas/, and",
|
||||||
|
"map each creature slug to its file name here.",
|
||||||
|
"",
|
||||||
|
"Both spawnAtlas.art.json and server/uploads/ are gitignored, so neither the map",
|
||||||
|
"nor the images can be committed by accident.",
|
||||||
|
"",
|
||||||
|
"Keys are creature slugs, as reported by the atlas API and derived from the type",
|
||||||
|
"names in your own shard's Spawns/*.xml. Values are file names relative to",
|
||||||
|
"server/uploads/atlas/. Any creature with no entry here simply renders without",
|
||||||
|
"art — that is the default and fully supported state, not a degraded one."
|
||||||
|
],
|
||||||
|
"lizardman": "lizardman.png",
|
||||||
|
"orc": "orc.png",
|
||||||
|
"dragon": "dragon.png"
|
||||||
|
}
|
||||||
@@ -188,8 +188,10 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
|||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
|
||||||
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
|
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
|
||||||
|
device_name VARCHAR(100) NULL, -- friendly label the app may send (M9)
|
||||||
user_agent VARCHAR(255) NULL,
|
user_agent VARCHAR(255) NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at DATETIME NULL, -- last time this session token was issued/used (M9)
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
revoked_at DATETIME NULL,
|
revoked_at DATETIME NULL,
|
||||||
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -197,6 +199,44 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
|||||||
INDEX idx_mrt_expires (expires_at)
|
INDEX idx_mrt_expires (expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Mobile SSO authorization bridge (M9). Two short-lived, self-pruning tables that
|
||||||
|
-- bridge a browser SSO redirect flow to the native app. They carry the app↔website
|
||||||
|
-- PKCE + CSRF state (a SECOND PKCE layer, distinct from the website↔IdP PKCE the
|
||||||
|
-- sso_tx cookie already carries) and the one-time code the app trades for bearer
|
||||||
|
-- tokens. No secret is stored in the clear: code_challenge is a hash by construction
|
||||||
|
-- and the authorization code is stored as a sha256 hash only (same pattern as
|
||||||
|
-- mobile_refresh_tokens / user_invites / password_resets). See docs BACKEND_DESIGN §3/§4.
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile_auth_sessions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
session_id CHAR(36) NOT NULL UNIQUE, -- uuid; carried inside the signed sso_tx (mode 'mobile')
|
||||||
|
provider VARCHAR(40) NOT NULL, -- provider id, validated enabled at /start
|
||||||
|
code_challenge VARCHAR(255) NOT NULL, -- app-supplied PKCE S256 challenge (base64url)
|
||||||
|
redirect_uri VARCHAR(255) NOT NULL, -- app callback; EXACT-match against the allowlist
|
||||||
|
state VARCHAR(255) NOT NULL, -- app-generated opaque CSRF value, echoed to the app
|
||||||
|
status ENUM('pending','completed','consumed') NOT NULL DEFAULT 'pending',
|
||||||
|
user_id INT NULL, -- set once SSO resolves the account
|
||||||
|
trust_device TINYINT(1) NOT NULL DEFAULT 0, -- user ticked "trust this device" on the Custom Tab TOTP form;
|
||||||
|
-- a BOOLEAN only — the trust token itself is minted at /exchange
|
||||||
|
-- and returned over that app→server call, never stored here
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL, -- ~10 min (one redirect round-trip incl. TOTP)
|
||||||
|
used_at DATETIME NULL, -- stamped at exchange
|
||||||
|
CONSTRAINT fk_mas_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_mas_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile_auth_codes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque >=128-bit code
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
session_id CHAR(36) NOT NULL, -- owning mobile_auth_sessions.session_id (ties code→PKCE challenge)
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL, -- very short (~5 min)
|
||||||
|
used_at DATETIME NULL, -- set on first successful exchange (single use)
|
||||||
|
CONSTRAINT fk_mac_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_mac_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
|
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
|
||||||
-- per session in createSession. A single logout adds this session's jti here;
|
-- per session in createSession. A single logout adds this session's jti here;
|
||||||
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
|
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
|
||||||
@@ -213,6 +253,50 @@ CREATE TABLE IF NOT EXISTS revoked_sessions (
|
|||||||
INDEX idx_revoked_sessions_expires (expires_at)
|
INDEX idx_revoked_sessions_expires (expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Trusted devices for MFA (opt-in "Trust this device"). A trusted device lets a
|
||||||
|
-- browser/app SKIP the TOTP step at login — never the password. Pattern-identical
|
||||||
|
-- to mobile_refresh_tokens: the opaque trust token lives client-side (the rg_trust
|
||||||
|
-- cookie on web, EncryptedSharedPreferences on mobile) and only its sha256 hash is
|
||||||
|
-- stored here (token_hash UNIQUE, so the login path can look a device up in O(1)).
|
||||||
|
-- sha256 (not bcrypt) because the token is a 256-bit random value looked up BY its
|
||||||
|
-- hash — a per-row salt would break the index lookup. Trust is consulted only at
|
||||||
|
-- the login/password step, never at token refresh, and is revoked on untrust /
|
||||||
|
-- password change/reset / TOTP disable. Capped at 10 rows per user (enforced in
|
||||||
|
-- application code — no silent pruning). See docs/website/TRUSTED_DEVICES_MFA.md.
|
||||||
|
CREATE TABLE IF NOT EXISTS trusted_devices (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque trust token
|
||||||
|
platform ENUM('web','mobile') NOT NULL DEFAULT 'web',
|
||||||
|
device_name VARCHAR(100) NULL, -- friendly label for the Trusted Devices list
|
||||||
|
device_hash VARCHAR(32) NULL, -- best-effort UA+IP (sessionMeta) — display only
|
||||||
|
user_agent VARCHAR(255) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at DATETIME NULL, -- stamped when trust is honored at login
|
||||||
|
expires_at DATETIME NOT NULL, -- created_at + 30d
|
||||||
|
revoked_at DATETIME NULL,
|
||||||
|
CONSTRAINT fk_td_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_td_user (user_id),
|
||||||
|
INDEX idx_td_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Single-use recovery (backup) codes for MFA. Generated at TOTP enrollment (10 at a
|
||||||
|
-- time, shown to the user ONCE) so a user who loses their authenticator can complete
|
||||||
|
-- login without an admin reset. code_hash is a BCRYPT hash (not sha256): a recovery
|
||||||
|
-- code is a human-typed, lower-entropy fallback credential — the closest analogue to
|
||||||
|
-- a password — and there is no hash-lookup constraint (we fetch the user's <=10 rows
|
||||||
|
-- and bcrypt.compare each, exactly like password verification). Cleared wholesale on
|
||||||
|
-- TOTP disable / password change/reset. See docs/website/TRUSTED_DEVICES_MFA.md.
|
||||||
|
CREATE TABLE IF NOT EXISTS recovery_codes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
code_hash VARCHAR(72) NOT NULL, -- bcrypt hash of one recovery code
|
||||||
|
used_at DATETIME NULL, -- single-use marker
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_rc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_rc_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
|
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
|
||||||
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
|
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
|
||||||
-- client secrets are, and is only ever decrypted server-side to push to the
|
-- client secrets are, and is only ever decrypted server-side to push to the
|
||||||
@@ -275,7 +359,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
|||||||
base_url VARCHAR(255) NULL,
|
base_url VARCHAR(255) NULL,
|
||||||
ws_url VARCHAR(255) NULL,
|
ws_url VARCHAR(255) NULL,
|
||||||
auth_token_enc TEXT NULL,
|
auth_token_enc TEXT NULL,
|
||||||
protocol INT NOT NULL DEFAULT 1,
|
protocol INT NOT NULL DEFAULT 3,
|
||||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||||
status_detail VARCHAR(500) NULL,
|
status_detail VARCHAR(500) NULL,
|
||||||
@@ -296,8 +380,9 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
|||||||
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
||||||
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
||||||
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
||||||
-- dedupe_key = sha1(kind + t + stable-json(payload)); with the UNIQUE index it
|
-- dedupe_key = sha256(kind + t + stable-json(payload)) truncated to 40 hex chars
|
||||||
-- makes INSERT IGNORE idempotent so WS-reconnect backfill never double-inserts.
|
-- (fits CHAR(40)); with the UNIQUE index it makes INSERT IGNORE idempotent so
|
||||||
|
-- WS-reconnect backfill never double-inserts.
|
||||||
CREATE TABLE IF NOT EXISTS shard_events (
|
CREATE TABLE IF NOT EXISTS shard_events (
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
kind VARCHAR(48) NOT NULL,
|
kind VARCHAR(48) NOT NULL,
|
||||||
@@ -512,6 +597,142 @@ CREATE TABLE IF NOT EXISTS shard_presence (
|
|||||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- The shard's published ruleset (Protocol 3.0 world.ruleset). Singleton row
|
||||||
|
-- (id = 1) holding the latest frame: expansion, which optional systems are on,
|
||||||
|
-- skill/stat caps, account and house limits, champion scroll rules, the
|
||||||
|
-- save/restart schedule. The shard re-emits it on every sidecar connect, so this
|
||||||
|
-- row is simply overwritten; `rev` is the shard's own FNV-1a of the body, which
|
||||||
|
-- distinguishes "same ruleset, re-sent on reconnect" from "an operator changed a
|
||||||
|
-- .cfg". No row at all means the shard has never published one — served as null,
|
||||||
|
-- which the rules page renders differently from a published ruleset.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_ruleset (
|
||||||
|
id INT PRIMARY KEY DEFAULT 1,
|
||||||
|
rev VARCHAR(32) NULL,
|
||||||
|
expansion VARCHAR(16) NULL, -- hoisted for cheap display
|
||||||
|
payload JSON NOT NULL, -- the whole world.ruleset frame
|
||||||
|
t BIGINT NULL, -- frame time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
|
||||||
|
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
|
||||||
|
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
|
||||||
|
-- players accumulate over months.
|
||||||
|
--
|
||||||
|
-- The top-N list stays inside `payload` rather than being normalized into a
|
||||||
|
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
|
||||||
|
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
|
||||||
|
-- buy nothing until something needs a per-character reverse lookup, and a
|
||||||
|
-- character's own standings already ride inside char.profile instead.
|
||||||
|
--
|
||||||
|
-- No delete path: the shard's set of systems is fixed at startup, so there is no
|
||||||
|
-- points.remove to mirror.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_points_boards (
|
||||||
|
system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty
|
||||||
|
name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal
|
||||||
|
name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number
|
||||||
|
max_points BIGINT NULL,
|
||||||
|
players INT NULL, -- players actually holding points in this system
|
||||||
|
show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag
|
||||||
|
payload JSON NOT NULL, -- the whole points.board frame, incl. `top`
|
||||||
|
t BIGINT NULL, -- frame time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
|
||||||
|
-- vendor and one per priced listing, so the site can offer the search the in-game
|
||||||
|
-- Vendor Search gump offers — from outside the game.
|
||||||
|
--
|
||||||
|
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
|
||||||
|
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
|
||||||
|
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, in
|
||||||
|
-- keeping with every other shard_* table: the ingest transaction is what keeps
|
||||||
|
-- them consistent, and an FK would turn a malformed frame into a failed write
|
||||||
|
-- rather than a dropped row.
|
||||||
|
--
|
||||||
|
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
|
||||||
|
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_vendors (
|
||||||
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234"
|
||||||
|
shop_name VARCHAR(160) NULL,
|
||||||
|
owner_serial VARCHAR(20) NULL,
|
||||||
|
owner_name VARCHAR(64) NULL,
|
||||||
|
map VARCHAR(40) NULL,
|
||||||
|
x INT NULL,
|
||||||
|
y INT NULL,
|
||||||
|
z INT NULL,
|
||||||
|
region VARCHAR(80) NULL,
|
||||||
|
house VARCHAR(160) NULL, -- the house SIGN's name, not the house type
|
||||||
|
item_count INT NOT NULL DEFAULT 0, -- listings published in the frame
|
||||||
|
item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds
|
||||||
|
truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count
|
||||||
|
t BIGINT NULL, -- frame time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_vendors_owner (owner_name),
|
||||||
|
INDEX idx_shard_vendors_map (map),
|
||||||
|
INDEX idx_shard_vendors_region (region),
|
||||||
|
-- The market page's staleness banner is MIN(updated_at) over this column: the
|
||||||
|
-- round-robin sweep means the oldest row is how far behind the index can be.
|
||||||
|
INDEX idx_shard_vendors_updated (updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- One priced listing. Unlike the points board's top-N — a fixed-size list read
|
||||||
|
-- whole — these are the searchable rows the whole feature exists for, so they are
|
||||||
|
-- normalized rather than left inside a payload column, and there is no payload
|
||||||
|
-- column on shard_vendors at all.
|
||||||
|
--
|
||||||
|
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
|
||||||
|
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
|
||||||
|
-- at query time would make the cliloc table a join on the hot path AND make
|
||||||
|
-- search-by-name impossible. Resolving once on write buys the index. It is
|
||||||
|
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
|
||||||
|
-- re-send an unchanged shop just because the site learned what its items are
|
||||||
|
-- called.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_vendor_items (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vendor_serial VARCHAR(20) NOT NULL,
|
||||||
|
serial VARCHAR(20) NOT NULL,
|
||||||
|
item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id)
|
||||||
|
hue INT NOT NULL DEFAULT 0,
|
||||||
|
amount INT NOT NULL DEFAULT 1,
|
||||||
|
price BIGINT NOT NULL DEFAULT 0,
|
||||||
|
name VARCHAR(160) NULL, -- the item's literal Name, null for most
|
||||||
|
cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs
|
||||||
|
display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches
|
||||||
|
child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself
|
||||||
|
INDEX idx_shard_vendor_items_vendor (vendor_serial),
|
||||||
|
INDEX idx_shard_vendor_items_price (price),
|
||||||
|
INDEX idx_shard_vendor_items_item (item_id),
|
||||||
|
INDEX idx_shard_vendor_items_name (display_name),
|
||||||
|
-- Search filters on name and sorts on price; the composite covers the common
|
||||||
|
-- "cheapest matching X" without a filesort over the whole table.
|
||||||
|
INDEX idx_shard_vendor_items_name_price (display_name, price)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
||||||
|
-- per feature; an absent row means "use the compiled default", and the compiled
|
||||||
|
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
||||||
|
-- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and
|
||||||
|
-- docs/link/v3.md §3 for the contract.
|
||||||
|
--
|
||||||
|
-- audience the minimum rung on anonymous < logged_in < player < staff < admin
|
||||||
|
-- stream whether this feature's kinds fan out over SSE at all (the market
|
||||||
|
-- index ships with this off: no page needs a live firehose of
|
||||||
|
-- whole vendor inventories)
|
||||||
|
-- field_rules {"<field>": "<rung>"} for SENSITIVE fields only. `acct` and
|
||||||
|
-- `webId` are admin-only always and are rejected here — they are
|
||||||
|
-- not in-game visible and are deliberately not configurable.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
|
||||||
|
feature VARCHAR(48) NOT NULL PRIMARY KEY,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
|
||||||
|
stream TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
field_rules JSON NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
|
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
|
||||||
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
|
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
|
||||||
-- which creates their website user at that role (and optionally a linked game
|
-- which creates their website user at that role (and optionally a linked game
|
||||||
@@ -536,6 +757,60 @@ CREATE TABLE IF NOT EXISTS user_invites (
|
|||||||
INDEX idx_user_invites_status (status, expires_at)
|
INDEX idx_user_invites_status (status, expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Self-service password resets. A user requests a reset by email; a tokened link
|
||||||
|
-- is emailed to every active account on that address. Opening the link and setting
|
||||||
|
-- a new password rotates the hash and revokes all sessions (web + mobile). Only the
|
||||||
|
-- sha256 hash of the opaque token is stored — a DB read never yields a usable link,
|
||||||
|
-- same as user_invites / mobile_refresh_tokens. Single-use + short-lived (1h,
|
||||||
|
-- enforced in the model on top of expires_at). Also serves SSO-only accounts (null
|
||||||
|
-- password_hash) as their "set an initial password" path.
|
||||||
|
CREATE TABLE IF NOT EXISTS password_resets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
||||||
|
user_id INT NOT NULL, -- the account this reset targets
|
||||||
|
status ENUM('pending','used') NOT NULL DEFAULT 'pending',
|
||||||
|
requested_ip VARCHAR(64) NULL, -- who asked (audit only)
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
used_at DATETIME NULL,
|
||||||
|
CONSTRAINT fk_password_resets_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_password_resets_user (user_id),
|
||||||
|
INDEX idx_password_resets_status (status, expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ── Push notifications (opt-in) ─────────────────────────────────────────────
|
||||||
|
-- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The
|
||||||
|
-- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed —
|
||||||
|
-- unguessable but NOT a secret (the security model treats ntfy as an untrusted
|
||||||
|
-- relay and only ever pushes content-free tickles), so it is stored in the clear,
|
||||||
|
-- unlike mobile_refresh_tokens. A device belongs to one user; re-registering the
|
||||||
|
-- same endpoint for the same user is an idempotent upsert (UNIQUE user_id+endpoint).
|
||||||
|
CREATE TABLE IF NOT EXISTS push_devices (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
transport ENUM('unifiedpush','fcm') NOT NULL DEFAULT 'unifiedpush',
|
||||||
|
endpoint VARCHAR(512) NOT NULL, -- distributor URL (or FCM token)
|
||||||
|
platform VARCHAR(40) NULL, -- e.g. 'android' (free-form label)
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_push_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uq_push_devices_user_endpoint (user_id, endpoint),
|
||||||
|
INDEX idx_push_devices_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Which notification streams a user has opted into. Subscriptions are per-user
|
||||||
|
-- (applied to every device the user has registered), not per-device. stream_id is
|
||||||
|
-- an id from the notification catalog (config/notificationStreams.js), validated
|
||||||
|
-- in the model on write. One row per (user, stream); PUT replaces the whole set.
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_subscriptions (
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
stream_id VARCHAR(64) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, stream_id),
|
||||||
|
CONSTRAINT fk_notif_subs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_notif_subs_stream (stream_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
|
||||||
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
|
||||||
-- writes them. They live in the same physical database as everything else
|
-- writes them. They live in the same physical database as everything else
|
||||||
@@ -573,6 +848,35 @@ CREATE TABLE IF NOT EXISTS mod_actions (
|
|||||||
INDEX idx_mod_actions_target (guild_id, target_user_id, created_at)
|
INDEX idx_mod_actions_target (guild_id, target_user_id, created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Player-submitted moderation appeals (Phase 6c). Unlike mod_actions above, this
|
||||||
|
-- table is SERVER-owned — written and read only by the main site (the player
|
||||||
|
-- appeals controller and the admin moderation queue), never by the bot. A player
|
||||||
|
-- appeals one of their own ban/mute mod_actions; staff triage the queue, and an
|
||||||
|
-- approval optionally triggers an automatic Discord reversal (Phase 6d) whose
|
||||||
|
-- outcome is recorded in reversal_status. mod_action_id is a plain column with NO
|
||||||
|
-- hard FK to the bot-owned mod_actions table (cross-owner FK avoided on purpose,
|
||||||
|
-- matching posts.announce_job_id) — existence is validated in app code. user_id
|
||||||
|
-- is the appealing site account; discord_user_id is the snowflake the appeal is
|
||||||
|
-- for (snapshotted from mod_actions.target_user_id at submit time).
|
||||||
|
CREATE TABLE IF NOT EXISTS appeals (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
mod_action_id INT NOT NULL,
|
||||||
|
discord_user_id VARCHAR(32) NOT NULL,
|
||||||
|
action_type ENUM('ban','mute') NOT NULL,
|
||||||
|
user_id INT NULL,
|
||||||
|
status ENUM('pending','under_review','approved','denied','withdrawn') NOT NULL DEFAULT 'pending',
|
||||||
|
submitted_text TEXT NOT NULL,
|
||||||
|
staff_response TEXT NULL,
|
||||||
|
handled_by_user_id INT NULL,
|
||||||
|
handled_by_tag VARCHAR(120) NULL,
|
||||||
|
reversal_status ENUM('none','done','failed') NOT NULL DEFAULT 'none',
|
||||||
|
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
resolved_at DATETIME NULL,
|
||||||
|
CONSTRAINT fk_appeal_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_appeals_status (status, submitted_at),
|
||||||
|
INDEX idx_appeals_action (mod_action_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Standing warnings, separate from mod_actions so /warnings can list active
|
-- Standing warnings, separate from mod_actions so /warnings can list active
|
||||||
-- warnings per user. expires_at is unused in Phase 2 (no decay/escalation
|
-- warnings per user. expires_at is unused in Phase 2 (no decay/escalation
|
||||||
-- yet — deferred, see mute/warn command comments) but the column is cheap to
|
-- yet — deferred, see mute/warn command comments) but the column is cheap to
|
||||||
@@ -831,6 +1135,189 @@ CREATE TABLE IF NOT EXISTS announce_jobs (
|
|||||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
||||||
|
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
||||||
|
-- and landmarks exist, and which champion altars are configured. Nothing here
|
||||||
|
-- comes from the sidecar — it is imported from a committed artifact built off a
|
||||||
|
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
|
||||||
|
-- these tables stay populated whether the shard is up or not.
|
||||||
|
--
|
||||||
|
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
|
||||||
|
-- in one transaction. Nothing else may write here, and nothing else may hold a
|
||||||
|
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
|
||||||
|
|
||||||
|
-- One row per spawnable type, aggregated across the world. `total` is the sum of
|
||||||
|
-- each type's own MX across every point that spawns it (how many exist at once);
|
||||||
|
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
||||||
|
-- live" both answer without touching shard_spawn_points.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
||||||
|
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
||||||
|
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
||||||
|
total INT NOT NULL DEFAULT 0,
|
||||||
|
points INT NOT NULL DEFAULT 0,
|
||||||
|
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
||||||
|
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
||||||
|
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
||||||
|
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
||||||
|
-- art when this is NULL, which is the normal case.
|
||||||
|
art VARCHAR(255) NULL,
|
||||||
|
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
||||||
|
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
||||||
|
INDEX idx_shard_spawn_creatures_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
||||||
|
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
||||||
|
-- the resolved display string (region, else landmark, else 'Wilderness').
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
facet VARCHAR(40) NOT NULL,
|
||||||
|
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
||||||
|
x INT NOT NULL,
|
||||||
|
y INT NOT NULL,
|
||||||
|
width INT NOT NULL DEFAULT 0,
|
||||||
|
height INT NOT NULL DEFAULT 0,
|
||||||
|
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
||||||
|
max_count INT NOT NULL DEFAULT 0,
|
||||||
|
min_delay INT NOT NULL DEFAULT 0,
|
||||||
|
max_delay INT NOT NULL DEFAULT 0,
|
||||||
|
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
||||||
|
tod_end INT NOT NULL DEFAULT 0,
|
||||||
|
tod_mode INT NOT NULL DEFAULT 0,
|
||||||
|
region VARCHAR(120) NULL,
|
||||||
|
landmark VARCHAR(120) NULL,
|
||||||
|
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||||
|
INDEX idx_shard_spawn_points_facet (facet),
|
||||||
|
INDEX idx_shard_spawn_points_label (label)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- The many-to-many between the two above: one spawner commonly carries several
|
||||||
|
-- types (a single Trammel point spawns six), each with its own max. This is how
|
||||||
|
-- /atlas/creatures/:slug finds the places a creature appears.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
||||||
|
point_id INT NOT NULL,
|
||||||
|
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
||||||
|
max_count INT NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (point_id, slug),
|
||||||
|
INDEX idx_shard_spawn_point_types_slug (slug)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
||||||
|
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
||||||
|
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_regions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
facet VARCHAR(40) NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
type VARCHAR(80) NULL, -- ServUO region class
|
||||||
|
priority INT NOT NULL DEFAULT 0,
|
||||||
|
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
||||||
|
rects JSON NULL,
|
||||||
|
INDEX idx_shard_regions_facet (facet),
|
||||||
|
INDEX idx_shard_regions_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
||||||
|
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
||||||
|
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
facet VARCHAR(40) NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
grp VARCHAR(120) NULL,
|
||||||
|
x INT NOT NULL,
|
||||||
|
y INT NOT NULL,
|
||||||
|
z INT NOT NULL DEFAULT 0,
|
||||||
|
INDEX idx_shard_landmarks_facet (facet),
|
||||||
|
INDEX idx_shard_landmarks_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
||||||
|
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
||||||
|
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
||||||
|
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
||||||
|
type VARCHAR(80) NULL, -- '' when randomised per activation
|
||||||
|
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
facet VARCHAR(40) NOT NULL,
|
||||||
|
x INT NOT NULL,
|
||||||
|
y INT NOT NULL,
|
||||||
|
z INT NOT NULL DEFAULT 0,
|
||||||
|
radius INT NOT NULL DEFAULT 0,
|
||||||
|
label VARCHAR(120) NULL, -- resolved place name
|
||||||
|
INDEX idx_shard_champion_spawns_facet (facet)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- UO's localization table: cliloc id -> display string. Items carry a
|
||||||
|
-- `LabelNumber` rather than a name, so without this the site can only render
|
||||||
|
-- `id 1023721` where the game shows "quarter staff". The shard has always sent
|
||||||
|
-- the id (char.profile's `cliloc`, and one per marketplace listing) — the number
|
||||||
|
-- was never the missing piece, the table was.
|
||||||
|
--
|
||||||
|
-- Sourced from a file the OPERATOR converts once from their own UO client and
|
||||||
|
-- points the site at (docs/website/CLILOCS.md); nothing derived from the client
|
||||||
|
-- is committed, the same rule the spawn atlas and the creature art map follow.
|
||||||
|
-- A shard with no cliloc file configured simply renders item ids, which is what
|
||||||
|
-- it did before this table existed.
|
||||||
|
--
|
||||||
|
-- `text` is TEXT, not VARCHAR: real tables top out around 12 KB for the long
|
||||||
|
-- property descriptions, and truncating them silently would be worse than
|
||||||
|
-- storing them. Item NAMES are all short — the index that matters for search is
|
||||||
|
-- on the denormalized `shard_vendor_items.display_name`, not here.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_clilocs (
|
||||||
|
number INT NOT NULL PRIMARY KEY,
|
||||||
|
flag SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
text TEXT NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Singleton (id = 1) describing the cliloc table currently loaded: the source
|
||||||
|
-- file, its sha256, the entry count and the parser version. The boot path
|
||||||
|
-- compares the stored hash against the file on disk and skips the parse when
|
||||||
|
-- they match, which is every restart that did not follow a client patch.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
|
||||||
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
||||||
|
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
||||||
|
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
||||||
|
-- is behind the committed artifact.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
||||||
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
||||||
|
-- NOT applied, because it would remove a facet the site currently serves.
|
||||||
|
--
|
||||||
|
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
||||||
|
-- much as of a real map change, and boot cannot tell the two apart — so the
|
||||||
|
-- refresh is staged here for a human instead of being applied. Startup is never
|
||||||
|
-- blocked by it: the site comes up serving the atlas it already had.
|
||||||
|
--
|
||||||
|
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
||||||
|
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
||||||
|
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
||||||
|
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
||||||
|
--
|
||||||
|
-- `rejected` is remembered against those exact source hashes so a declined
|
||||||
|
-- refresh does not re-prompt on every restart; changing the tree changes the
|
||||||
|
-- hashes and asks again.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||||
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||||
|
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
||||||
|
payload JSON NOT NULL, -- source hashes + facet diff
|
||||||
|
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||||
@@ -898,3 +1385,31 @@ ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
|
|||||||
-- so the public Houses browser can list registered houses without pulling in rows
|
-- so the public Houses browser can list registered houses without pulling in rows
|
||||||
-- we only ever saw an IDOC transition for.
|
-- we only ever saw an IDOC transition for.
|
||||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Mobile device sessions (M9): a friendly label the app may send at login, and
|
||||||
|
-- the last time this session token was issued/used, for the "Active Devices"
|
||||||
|
-- self-service list. Both nullable and additive; existing rows get them here.
|
||||||
|
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS device_name VARCHAR(100) NULL;
|
||||||
|
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME NULL;
|
||||||
|
|
||||||
|
-- SSO trusted devices: records that the user ticked "trust this device" on the
|
||||||
|
-- Custom Tab TOTP form, so /auth/mobile/sso/exchange knows to mint the app's own
|
||||||
|
-- trust token. A boolean only — the token is returned over that app→server call
|
||||||
|
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||||
|
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset,
|
||||||
|
-- points.board, vendor.listing), so the pinned version an existing install
|
||||||
|
-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call
|
||||||
|
-- and closes the WS on ws.hello. MODIFY fixes the column default for installs
|
||||||
|
-- created before the bump (idempotent, like the other MODIFYs here).
|
||||||
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
|
||||||
|
-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this
|
||||||
|
-- must be one-shot: an operator who deliberately pins an older sidecar in
|
||||||
|
-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes
|
||||||
|
-- it fire once — written after the UPDATE, and on a fresh install (no
|
||||||
|
-- uo_link_config row yet) it is simply written with nothing to update.
|
||||||
|
UPDATE uo_link_config SET protocol = 3
|
||||||
|
WHERE id = 1 AND protocol < 3
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
|
||||||
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const DEFAULT_SETTINGS = {
|
|||||||
'future news, screenshots, guides, and community notes as the world comes online.',
|
'future news, screenshots, guides, and community notes as the world comes online.',
|
||||||
contact_email: brand.contactEmail,
|
contact_email: brand.contactEmail,
|
||||||
site_title: brand.name,
|
site_title: brand.name,
|
||||||
|
// Android App Links opt-in — off until an admin enables it (docs/android/APP_LINKS.md).
|
||||||
|
mobile_app_links_enabled: 'false',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starter wiki sections (editable later via the admin panel).
|
// Starter wiki sections (editable later via the admin panel).
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
"dev": "nodemon src/server.js",
|
"dev": "nodemon src/server.js",
|
||||||
"seed": "node db/seed.js",
|
"seed": "node db/seed.js",
|
||||||
"swagger": "node swagger/swagger.js",
|
"swagger": "node swagger/swagger.js",
|
||||||
|
"routes:manifest": "node scripts/routeManifest.js",
|
||||||
|
"atlas:import": "node scripts/importSpawnAtlas.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
2157
server/routes.guards.json
Normal file
2157
server/routes.guards.json
Normal file
File diff suppressed because it is too large
Load Diff
907
server/routes.manifest.json
Normal file
907
server/routes.manifest.json
Normal file
@@ -0,0 +1,907 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Generated route inventory - the authoritative freeze of the URL surface. Regenerate with `npm run routes:manifest` in website/server; a domain-split PR must produce a zero-line diff here.",
|
||||||
|
"public": [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/.well-known/assetlinks.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/csp-report"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/docs.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/health"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/account/identities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/account/identities/:provider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/account/totp/disable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/account/totp/enable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/account/totp/setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/activity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/auth/providers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/auth/providers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/auth/providers/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/auth/providers/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/bot-activity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/bot-activity/unban"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/dashboard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/discord-bot/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/discord-bot/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/email/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/email/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/email/connect/callback"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/email/connect/start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/email/disconnect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/email/test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/invites"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/invites"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/invites/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/appeals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/appeals/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/moderation/appeals/:id/claim"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/moderation/appeals/:id/resolve"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/filter-hits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/members"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/recent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/spam-hits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/stats/summary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId/actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId/appeals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId/notes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId/notes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/pages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/pages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/pages/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/pages/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/admin/pages/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/pages/:id/preview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/pages/:id/unprotect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/posts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/posts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/posts/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/posts/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/posts/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/posts/:id/announce"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/posts/:id/announce/retry"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/admin/posts/:id/publish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/posts/upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/atlas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/atlas/approve"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/atlas/import"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/atlas/path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/atlas/reject"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/audit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/ban"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/broadcast"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/char/:serial"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs/import"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs/path"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/houses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/kick"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/pages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/pages/:id/close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/pages/:id/respond"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/roster/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/sales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/unban"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/vendors/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/visibility"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/visibility"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/site-mode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/uo-link/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/uo-link/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/uo-link/stream"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/uo-link/towncrier"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/uo-link/towncrier/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/users/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/users/:id/mfa/reset"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/houses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/link/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/sales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/standing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/wiki"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug/publish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug/revisions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug/revisions/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug/revisions/:id/restore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/categories"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/wiki/categories"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/wiki/categories/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/wiki/categories/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/tags"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/invite/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/invite/:token/accept"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/login"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/login/totp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/logout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/account/identities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/account/identities/:provider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/auth/me/account/password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/account/recovery-codes/generate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/account/recovery-codes/status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/account/totp/disable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/account/totp/enable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/account/totp/setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/auth/me/account/username"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/devices/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/notifications/streams"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/sessions/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/trusted-devices/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/mobile/login"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/mobile/logout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/mobile/refresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/mobile/sso/exchange"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/mobile/sso/start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/password/forgot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/password/reset/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/password/reset/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/providers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/register"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/sso/:provider/callback"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/sso/:provider/link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/sso/:provider/start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/sso/totp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/account/identities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/player/account/identities/:provider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/player/account/password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/account/totp/disable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/account/totp/enable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/account/totp/setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/player/account/username"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/appeals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/appeals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/appeals/:id/withdraw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/appeals/eligible"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/shard/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/char/:serial"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/houses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/shard/link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/roster/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/sales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/vendors/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/champions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/creatures"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/creatures/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/landmarks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/meta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/regions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/public/contact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/pages/:id/preview/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/pages/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/posts/:category"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/posts/:category/:idOrSlug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/champs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/economy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/features"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/feed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/governors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/governors/:city/history"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/guilds"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/houses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/idoc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market/meta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market/vendors/:serial"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/points"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/points/:system"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/presence"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/ruleset"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/stream"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/version"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/wiki"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/wiki/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/wiki/categories"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/wiki/tags"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"internal": [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/health"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/internal/bot-config"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
127
server/scripts/importSpawnAtlas.js
Normal file
127
server/scripts/importSpawnAtlas.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
//
|
||||||
|
// Refresh the spawn atlas from a ServUO tree, from the command line.
|
||||||
|
//
|
||||||
|
// npm run atlas:import # use the configured path
|
||||||
|
// npm run atlas:import -- --servuo <path> # override it for this run
|
||||||
|
// npm run atlas:import -- --force # reimport even if unchanged
|
||||||
|
// npm run atlas:import -- --approve # apply a staged refresh
|
||||||
|
// npm run atlas:import -- --status # report without changing anything
|
||||||
|
//
|
||||||
|
// The server does this itself on every boot (see `shardAtlas.refreshOnBoot`), so
|
||||||
|
// this is for operators who want to apply a map change without a restart, and
|
||||||
|
// for approving a refresh that was staged because it would remove a facet.
|
||||||
|
//
|
||||||
|
// All the logic lives in `src/model/shardAtlas/shardAtlas.model.js`; this file
|
||||||
|
// is argument parsing and output formatting.
|
||||||
|
|
||||||
|
const db = () => require('../src/utils/db')
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {}
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const flag = argv[i]
|
||||||
|
if (flag === '--servuo') args.servuo = argv[++i]
|
||||||
|
else if (flag === '--force') args.force = true
|
||||||
|
else if (flag === '--approve') args.approve = true
|
||||||
|
else if (flag === '--reject') args.reject = true
|
||||||
|
else if (flag === '--status') args.status = true
|
||||||
|
else if (flag === '--help' || flag === '-h') args.help = true
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE = `
|
||||||
|
Refresh the spawn atlas from a ServUO tree.
|
||||||
|
|
||||||
|
node scripts/importSpawnAtlas.js [options]
|
||||||
|
|
||||||
|
--servuo <path> Use this tree for this run instead of the configured path.
|
||||||
|
--force Reimport even when the source files are unchanged.
|
||||||
|
--approve Apply a refresh that was staged for removing a facet.
|
||||||
|
--reject Keep the current atlas and dismiss the staged refresh.
|
||||||
|
--status Report atlas and source state; change nothing.
|
||||||
|
|
||||||
|
With no options this imports only if the tree differs from what is loaded.
|
||||||
|
`
|
||||||
|
|
||||||
|
function describe(result) {
|
||||||
|
switch (result.status) {
|
||||||
|
case 'skipped':
|
||||||
|
return (
|
||||||
|
'No ServUO path configured — nothing to import.\n' +
|
||||||
|
'Set one with SERVUO_PATH, the admin panel, or --servuo <path>.\n'
|
||||||
|
)
|
||||||
|
case 'unavailable':
|
||||||
|
return `ServUO tree unavailable: ${result.reason}\n`
|
||||||
|
case 'unchanged':
|
||||||
|
return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n`
|
||||||
|
case 'needsReview': {
|
||||||
|
return (
|
||||||
|
'Refresh NOT applied — it would remove ' +
|
||||||
|
`${result.removedFacets.length} facet(s): ${result.removedFacets.join(', ')}.\n` +
|
||||||
|
'This is what a half-copied or mid-update tree looks like, so it has been\n' +
|
||||||
|
'staged for review. The current atlas is unchanged.\n' +
|
||||||
|
'Apply it with --approve, or dismiss it with --reject.\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'imported': {
|
||||||
|
const c = result.counts
|
||||||
|
const added = result.addedFacets?.length ? ` Added facets: ${result.addedFacets.join(', ')}.` : ''
|
||||||
|
const removed = result.removedFacets?.length
|
||||||
|
? ` Removed facets: ${result.removedFacets.join(', ')}.`
|
||||||
|
: ''
|
||||||
|
return (
|
||||||
|
`Atlas imported: ${c.points} points, ${c.creatures} creatures, ` +
|
||||||
|
`${c.pointTypes} point/type rows, ${c.regions} regions, ` +
|
||||||
|
`${c.landmarks} landmarks, ${c.champions} champion altars.${added}${removed}\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'failed':
|
||||||
|
return `Atlas refresh failed: ${result.reason}\n`
|
||||||
|
default:
|
||||||
|
return `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
if (args.help) {
|
||||||
|
process.stdout.write(USAGE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||||||
|
|
||||||
|
// `--servuo` is a per-run override and deliberately does NOT persist to the
|
||||||
|
// configured path; changing where the atlas permanently reads from is an
|
||||||
|
// admin action, not a side effect of a one-off import.
|
||||||
|
const override = { path: args.servuo ?? '' }
|
||||||
|
|
||||||
|
if (args.status) {
|
||||||
|
process.stdout.write(`${JSON.stringify(await shardAtlas.status(override), null, 2)}\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (args.reject) {
|
||||||
|
process.stdout.write(`${JSON.stringify(await shardAtlas.rejectPending(), null, 2)}\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = args.approve
|
||||||
|
? await shardAtlas.approvePending(override)
|
||||||
|
: await shardAtlas.refresh({ ...override, force: Boolean(args.force) })
|
||||||
|
|
||||||
|
process.stdout.write(describe(result))
|
||||||
|
if (result.status === 'failed') process.exitCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
process.stderr.write(`atlas:import failed: ${err.message}\n`)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(() => db().close())
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { describe, parseArgs }
|
||||||
261
server/scripts/routeManifest.js
Normal file
261
server/scripts/routeManifest.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Route manifest generator — the machine-readable freeze of the HTTP URL surface.
|
||||||
|
*
|
||||||
|
* Why this exists: the router files are being carved up by business capability
|
||||||
|
* (docs/website/API_V2_PLAN.md § Phase 2) with the explicit promise that not one
|
||||||
|
* URL moves. "Every URL is unchanged" has to be proved by a diff, not asserted in
|
||||||
|
* review, so this walks the *live* Express stack and writes a sorted
|
||||||
|
* `{ method, path }` list. CI regenerates it and fails on any diff; a PR that
|
||||||
|
* really does change a URL has to commit the new manifest, which puts the change
|
||||||
|
* in front of a reviewer instead of letting it slip through a "mechanical" PR.
|
||||||
|
*
|
||||||
|
* Runtime introspection, not source parsing: it is authoritative about mounts, and
|
||||||
|
* a route's path sits on the line *after* `router.get(`, which defeats naive
|
||||||
|
* greps. Not swagger-output.json either — that is annotation-
|
||||||
|
* derived (only annotated routes appear) and documents intent; this records reality.
|
||||||
|
*
|
||||||
|
* Scope: only `/api/**` and `/.well-known/**` from the public app, plus everything
|
||||||
|
* on the internal app. Three mounts in app.js are *filesystem* conditional — the SPA
|
||||||
|
* catch-all `GET *`, the `/brand` static mount and swagger-ui's `/api/docs` static
|
||||||
|
* assets — so including them would make the output depend on whether CI had built
|
||||||
|
* the client. Static mounts are not API contract.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run routes:manifest # write server/routes.manifest.json (+ guards)
|
||||||
|
* npm run routes:manifest -- --check # exit 1 if the committed files are stale
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The apps pull in models -> utils/db, which builds a mariadb pool at require time.
|
||||||
|
// Point it at a closed port (same trick the test suite uses) so generating a
|
||||||
|
// manifest never opens a real connection or hangs on a missing database.
|
||||||
|
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||||
|
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const app = require('../src/app')
|
||||||
|
const internalApp = require('../src/internalApp')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
const SERVER_ROOT = path.join(__dirname, '..')
|
||||||
|
const MANIFEST_PATH = path.join(SERVER_ROOT, 'routes.manifest.json')
|
||||||
|
const GUARDS_PATH = path.join(SERVER_ROOT, 'routes.guards.json')
|
||||||
|
|
||||||
|
const MANIFEST_COMMENT =
|
||||||
|
'Generated route inventory - the authoritative freeze of the URL surface. ' +
|
||||||
|
'Regenerate with `npm run routes:manifest` in website/server; a domain-split PR ' +
|
||||||
|
'must produce a zero-line diff here.'
|
||||||
|
|
||||||
|
const GUARDS_COMMENT =
|
||||||
|
'Generated review aid, NOT a gated contract - per route, the middleware handler ' +
|
||||||
|
'count and the *named* middleware collected along the mount chain. Anonymous ' +
|
||||||
|
'handlers (e.g. the arrow returned by requireRole(...)) cannot be named, so this ' +
|
||||||
|
'is a hint for reviewers, never a security check. Regenerate with ' +
|
||||||
|
'`npm run routes:manifest`.'
|
||||||
|
|
||||||
|
// Only these prefixes are contract. Everything else the public app serves (SPA
|
||||||
|
// shell, /uploads, /brand, swagger-ui assets) is static delivery, not API surface.
|
||||||
|
const PUBLIC_PREFIXES = ['/api/', '/.well-known/']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the literal path a router was mounted at from the layer's regexp.
|
||||||
|
*
|
||||||
|
* Express keeps no copy of the mount string, only the compiled regexp. For a
|
||||||
|
* literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount
|
||||||
|
* contributes one `(?:([^\/]+?))` group per entry in `layer.keys`. Unwinding both
|
||||||
|
* gets us back to `/api/v1` and `/thing/:id` respectively. `fast_slash` is
|
||||||
|
* express's marker for a router mounted at the root, which contributes nothing.
|
||||||
|
*/
|
||||||
|
function mountPath(layer) {
|
||||||
|
const re = layer.regexp
|
||||||
|
if (!re || re.fast_slash) return ''
|
||||||
|
|
||||||
|
let src = re.source
|
||||||
|
.replace(/^\^/, '')
|
||||||
|
.replace(/\\\/\?\(\?=\\\/\|\$\)$/, '') // mount tail: \/?(?=\/|$)
|
||||||
|
.replace(/\$$/, '')
|
||||||
|
|
||||||
|
const keys = layer.keys || []
|
||||||
|
let i = 0
|
||||||
|
src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => {
|
||||||
|
const key = keys[i++]
|
||||||
|
return key ? `:${key.name}` : ':param'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Whatever is left should be a literal path with regexp-escaped separators.
|
||||||
|
src = src.replace(/\\(.)/g, '$1')
|
||||||
|
|
||||||
|
if (/[()[\]?*+|^$]/.test(src)) {
|
||||||
|
throw new Error(
|
||||||
|
`routeManifest: could not decode mount path from regexp ${re.source} (got "${src}"). ` +
|
||||||
|
'A non-literal mount was added — teach mountPath() about it rather than guessing.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `layer.name` is 'router' for a mounted Router, and the fn name otherwise. */
|
||||||
|
function isRouter(layer) {
|
||||||
|
return layer.name === 'router' && layer.handle && Array.isArray(layer.handle.stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Named middleware only — anonymous handlers have `name === ''`. */
|
||||||
|
function namedMiddleware(handlers) {
|
||||||
|
return handlers
|
||||||
|
.map((h) => h && h.name)
|
||||||
|
.filter((n) => n && n !== 'anonymous' && n !== 'bound dispatch')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk an Express stack, collecting one entry per (method, path). `prefix` is the
|
||||||
|
* path accumulated from enclosing mounts; `gates` the named router-level middleware
|
||||||
|
* seen on the way down (a `router.use(noindex, isLoggedIn, …)` gate never appears in
|
||||||
|
* an individual route's own stack, so it has to be carried down).
|
||||||
|
*
|
||||||
|
* `depth === 0` is the app's own stack — helmet, morgan, the JSON parser, the bot
|
||||||
|
* guard. Those apply to literally every route, so recording them would bury the
|
||||||
|
* per-route gates that actually matter under a dozen identical names.
|
||||||
|
*/
|
||||||
|
function walk(stack, prefix, gates, out, depth = 0) {
|
||||||
|
const inherited = [...gates]
|
||||||
|
|
||||||
|
for (const layer of stack) {
|
||||||
|
if (layer.route) {
|
||||||
|
const routePaths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path]
|
||||||
|
// The last handler is the controller, not a gate; everything before it is.
|
||||||
|
const guards = layer.route.stack.slice(0, -1).map((s) => s.handle)
|
||||||
|
for (const routePath of routePaths) {
|
||||||
|
const full = normalize(prefix + routePath)
|
||||||
|
for (const method of Object.keys(layer.route.methods)) {
|
||||||
|
if (method === '_all') continue
|
||||||
|
out.push({
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
path: full,
|
||||||
|
handlers: layer.route.stack.length,
|
||||||
|
gates: [...inherited, ...namedMiddleware(guards)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isRouter(layer)) {
|
||||||
|
walk(layer.handle.stack, prefix + mountPath(layer), inherited, out, depth + 1)
|
||||||
|
} else if (depth > 0 && layer.name && layer.name !== '<anonymous>') {
|
||||||
|
// A bare `use()` on a mounted router — a gate applying to everything after it.
|
||||||
|
inherited.push(layer.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse `//` from empty mount paths and drop a trailing slash. */
|
||||||
|
function normalize(p) {
|
||||||
|
const collapsed = p.replace(/\/{2,}/g, '/')
|
||||||
|
return collapsed.length > 1 ? collapsed.replace(/\/$/, '') : collapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort by path, then method — stable and diff-friendly. */
|
||||||
|
function bySurface(a, b) {
|
||||||
|
if (a.path !== b.path) return a.path < b.path ? -1 : 1
|
||||||
|
if (a.method !== b.method) return a.method < b.method ? -1 : 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupe(entries) {
|
||||||
|
const seen = new Map()
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = `${e.method} ${e.path}`
|
||||||
|
if (!seen.has(key)) seen.set(key, e)
|
||||||
|
}
|
||||||
|
return [...seen.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect the full route table for both listeners. */
|
||||||
|
function collect() {
|
||||||
|
const publicRoutes = []
|
||||||
|
walk(app._router.stack, '', [], publicRoutes)
|
||||||
|
|
||||||
|
const internalRoutes = []
|
||||||
|
walk(internalApp._router.stack, '', [], internalRoutes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
public: dedupe(
|
||||||
|
publicRoutes.filter((r) => PUBLIC_PREFIXES.some((p) => r.path.startsWith(p))),
|
||||||
|
).sort(bySurface),
|
||||||
|
internal: dedupe(internalRoutes).sort(bySurface),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gated contract: method + path only, which is exactly what must not change. */
|
||||||
|
function buildManifest(collected) {
|
||||||
|
const strip = (rs) => rs.map((r) => ({ method: r.method, path: r.path }))
|
||||||
|
return {
|
||||||
|
$comment: MANIFEST_COMMENT,
|
||||||
|
public: strip(collected.public),
|
||||||
|
internal: strip(collected.internal),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ungated review aid: same routes, plus handler count and named gates. */
|
||||||
|
function buildGuards(collected) {
|
||||||
|
const shape = (rs) =>
|
||||||
|
rs.map((r) => ({
|
||||||
|
method: r.method,
|
||||||
|
path: r.path,
|
||||||
|
handlers: r.handlers,
|
||||||
|
gates: [...new Set(r.gates)],
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
$comment: GUARDS_COMMENT,
|
||||||
|
public: shape(collected.public),
|
||||||
|
internal: shape(collected.internal),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always LF + a trailing newline so the file is byte-identical on Windows and CI.
|
||||||
|
function serialize(obj) {
|
||||||
|
return `${JSON.stringify(obj, null, 2)}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const check = process.argv.includes('--check')
|
||||||
|
const collected = collect()
|
||||||
|
const files = [
|
||||||
|
[MANIFEST_PATH, serialize(buildManifest(collected))],
|
||||||
|
[GUARDS_PATH, serialize(buildGuards(collected))],
|
||||||
|
]
|
||||||
|
|
||||||
|
let stale = 0
|
||||||
|
for (const [file, contents] of files) {
|
||||||
|
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n') : null
|
||||||
|
if (check) {
|
||||||
|
if (current !== contents) {
|
||||||
|
process.stderr.write(`stale: ${path.relative(SERVER_ROOT, file)}\n`)
|
||||||
|
stale += 1
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = collected.public.length + collected.internal.length
|
||||||
|
if (check) {
|
||||||
|
if (stale) {
|
||||||
|
process.stderr.write('Run `npm run routes:manifest` and commit the result.\n')
|
||||||
|
process.exitCode = 1
|
||||||
|
} else {
|
||||||
|
process.stdout.write(`route manifest up to date (${total} routes)\n`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process.stdout.write(
|
||||||
|
`wrote routes.manifest.json (${collected.public.length} public + ${collected.internal.length} internal)\n`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main()
|
||||||
|
// The mariadb pool keeps the loop alive even pointed at a dead port.
|
||||||
|
db.close().finally(() => process.exit(process.exitCode || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { collect, buildManifest, buildGuards, serialize, mountPath }
|
||||||
@@ -10,7 +10,11 @@ require('dotenv').config()
|
|||||||
const swaggerUi = require('swagger-ui-express')
|
const swaggerUi = require('swagger-ui-express')
|
||||||
|
|
||||||
const apiRouter = require('./router/api.router')
|
const apiRouter = require('./router/api.router')
|
||||||
|
const wellKnown = require('./router/wellKnown.controller')
|
||||||
|
const cspReport = require('./router/cspReport.controller')
|
||||||
const brand = require('./config/brand')
|
const brand = require('./config/brand')
|
||||||
|
const csp = require('./config/csp')
|
||||||
|
const { cspReportLimiter } = require('./middleware/rateLimit')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||||
const botScore = require('./middleware/botScore')
|
const botScore = require('./middleware/botScore')
|
||||||
@@ -35,15 +39,32 @@ app.use(trustProxyDebug)
|
|||||||
// obvious scanner probes are 404'd immediately without reaching real handlers.
|
// obvious scanner probes are 404'd immediately without reaching real handlers.
|
||||||
app.use(botScore.guard)
|
app.use(botScore.guard)
|
||||||
|
|
||||||
// Security headers. CSP is left off here and will be tuned for the React SPA in
|
// Security headers, including a Content-Security-Policy tuned for the built React
|
||||||
// the frontend phase; the rest of helmet's protections stay enabled.
|
// SPA. The policies themselves (and the reasoning behind every non-'self' allowance)
|
||||||
|
// live in config/csp.js. The interactive API docs at /api/docs get their own looser
|
||||||
|
// policy below.
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
contentSecurityPolicy: false,
|
contentSecurityPolicy: { useDefaults: true, directives: csp.enforced },
|
||||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The tightened policy rides alongside on Content-Security-Policy-Report-Only for one
|
||||||
|
// release, then replaces the enforced one (docs/website/API_V2_PLAN.md § Phase 1).
|
||||||
|
// Both headers are served at once on purpose: the live policy keeps protecting users
|
||||||
|
// while anything the tightened version would have broken shows up as a report at
|
||||||
|
// /api/csp-report instead of as a broken page. Reports are same-origin — they
|
||||||
|
// describe attacks on this site and are not handed to a third party.
|
||||||
|
app.use(csp.reportingEndpoints)
|
||||||
|
app.use(
|
||||||
|
helmet.contentSecurityPolicy({
|
||||||
|
useDefaults: true,
|
||||||
|
reportOnly: true,
|
||||||
|
directives: csp.reportOnly,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// CORS only when a separate client origin is configured (local Vite dev). In
|
// CORS only when a separate client origin is configured (local Vite dev). In
|
||||||
// production the SPA is same-origin, so no CORS is needed.
|
// production the SPA is same-origin, so no CORS is needed.
|
||||||
if (process.env.CLIENT_ORIGIN) {
|
if (process.env.CLIENT_ORIGIN) {
|
||||||
@@ -122,7 +143,18 @@ try {
|
|||||||
// #swagger.ignore = true
|
// #swagger.ignore = true
|
||||||
res.json(swaggerSpec)
|
res.json(swaggerSpec)
|
||||||
})
|
})
|
||||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
// swagger-ui-express injects an inline bootstrap script and inline styles, which
|
||||||
|
// the global 'self'-only script-src would block — relax CSP for this route only.
|
||||||
|
const swaggerCsp = helmet.contentSecurityPolicy({
|
||||||
|
useDefaults: true,
|
||||||
|
directives: {
|
||||||
|
'script-src': ["'self'", "'unsafe-inline'"],
|
||||||
|
'style-src': ["'self'", "'unsafe-inline'"],
|
||||||
|
'img-src': ["'self'", 'data:', 'https:'],
|
||||||
|
'upgrade-insecure-requests': null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app.use('/api/docs', swaggerCsp, swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||||
customSiteTitle: `${brand.name} API docs`,
|
customSiteTitle: `${brand.name} API docs`,
|
||||||
swaggerOptions: { persistAuthorization: true },
|
swaggerOptions: { persistAuthorization: true },
|
||||||
}))
|
}))
|
||||||
@@ -140,9 +172,20 @@ app.get(
|
|||||||
/* #swagger.responses[200] = { description: 'Service is up', content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", example: "ok" } } } } } } */
|
/* #swagger.responses[200] = { description: 'Service is up', content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", example: "ok" } } } } } } */
|
||||||
(req, res) => res.json({ status: 'ok' }),
|
(req, res) => res.json({ status: 'ok' }),
|
||||||
)
|
)
|
||||||
|
// CSP violation sink. Mounted here, ahead of the /api 404, and outside /api/v1: it is
|
||||||
|
// not part of the versioned client contract — it exists for the browser, which learns
|
||||||
|
// the path from the policy header, never from a client build.
|
||||||
|
app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive)
|
||||||
|
|
||||||
app.use('/api', apiRouter)
|
app.use('/api', apiRouter)
|
||||||
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
||||||
|
|
||||||
|
// ── /.well-known ──────────────────────────────────────────────────────
|
||||||
|
// Android App Links verification file at the web root (M9 follow-up). Mounted
|
||||||
|
// before the SPA catch-all so it returns JSON, not the index shell. 404s unless
|
||||||
|
// the admin has enabled App Links for this shard (docs/android/APP_LINKS.md).
|
||||||
|
app.get('/.well-known/assetlinks.json', wellKnown.assetlinks)
|
||||||
|
|
||||||
// ── Client SPA ────────────────────────────────────────────────────────
|
// ── Client SPA ────────────────────────────────────────────────────────
|
||||||
// Serve the built React app if present; otherwise show a placeholder so the
|
// Serve the built React app if present; otherwise show a placeholder so the
|
||||||
// server is usable API-only before the frontend phase.
|
// server is usable API-only before the frontend phase.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user