From a134c471528e990f4f0799df0d2590f7eeaa8d6f Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sun, 9 Aug 2026 13:06:37 +0200 Subject: [PATCH] Add Phase 6: multi-server admin panel (server registry + settings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate a new /api/admin/* route set (register/list/update/delete servers) behind a single shared MCMAPPER_ADMIN_TOKEN header, and add a /admin frontend page (Alpine) to unlock, register new servers, and edit authMode/anonymousChatAllowed/waypointFormat per server — these columns already existed but were only editable via direct DB edit until now. Written test-first per the project's TDD workflow: admin.ts's domain logic, the index.ts route wiring, and a new e2e/tests/admin.spec.ts covering the token gate and register/edit/delete round trip through the real browser UI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt --- README.md | 45 +++++++---- api/.env.example | 11 ++- api/src/admin.test.ts | 131 ++++++++++++++++++++++++++++++++ api/src/admin.ts | 73 ++++++++++++++++++ api/src/index.test.ts | 87 ++++++++++++++++++++- api/src/index.ts | 49 ++++++++++++ e2e/config.ts | 5 ++ e2e/global-setup.ts | 3 + e2e/tests/admin.spec.ts | 75 ++++++++++++++++++ frontend/src/index.ts | 3 + frontend/src/public/js/admin.js | 98 ++++++++++++++++++++++++ frontend/src/views/admin.pug | 69 +++++++++++++++++ frontend/src/views/index.pug | 1 + 13 files changed, 634 insertions(+), 16 deletions(-) create mode 100644 api/src/admin.test.ts create mode 100644 api/src/admin.ts create mode 100644 e2e/tests/admin.spec.ts create mode 100644 frontend/src/public/js/admin.js create mode 100644 frontend/src/views/admin.pug diff --git a/README.md b/README.md index 3f7d1e3..6280dee 100644 --- a/README.md +++ b/README.md @@ -61,20 +61,37 @@ docker compose up migrations on startup (see `api/src/db/migrate.ts`); `worker` consumes the `mcmapper:dirty-chunks` Redis stream via a consumer group (`mcmapper-workers`) so multiple instances split work safely. -### Phase 1: connecting a mod instance +### Connecting a mod instance -There's no admin registration API yet (Phase 6) — seed one server row by hand, then point the -mod's `MapperConfig` at the same token: +Register a server via the admin panel at `/admin` (set `MCMAPPER_ADMIN_TOKEN` first — see +"Admin panel" below), or seed one row by hand for scripted/headless setup: ``` docker compose run --rm api bun run seed ``` reads `MCMAPPER_SEED_SERVER_NAME`/`MCMAPPER_SEED_SERVER_TOKEN` from `api/.env.example` (edit -those first, or override with `-e`). Once the mod connects and sends its initial chunk backfill, -tiles appear at `GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY.png` (zoom is always `0` -for now — see `worker/src/render/cpu.rs`) and the frontend's Leaflet viewer picks them up -automatically from `GET /api/servers`. +those first, or override with `-e`). Either way, point the mod's `MapperConfig#serverToken` at +the resulting token. Once the mod connects and sends its initial chunk backfill, tiles appear at +`GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY.png` (zoom is always `0` for now — see +`worker/src/render/cpu.rs`) and the frontend's Leaflet viewer picks them up automatically from +`GET /api/servers`. + +### Admin panel + +`/admin` (linked from the map page's header) manages the server registry: register new servers +(generates their token — never admin-supplied, so it can't collide with or be guessed from +anything else), and edit `authMode`, `anonymousChatAllowed`, and `waypointFormat` per server. +It's gated behind a single shared secret, not a per-account role (this is a single-operator +backend) — set `MCMAPPER_ADMIN_TOKEN` in `api/`'s untracked `.env` (see `api/.env.example`), +restart `api`, then enter that same value into the panel's unlock prompt. Leaving it unset +disables every `/api/admin/*` route (401), it does not default to open. The token is remembered +in the browser's `localStorage` after unlocking, same pattern as the player-facing session token +(see `api/src/link.ts`'s doc comment). + +Settings not exposed here yet (which map types render, dimension filtering, player-position +visibility) don't have underlying features built for them either — no point in a knob nothing +reads. They'll gain admin UI alongside the feature that needs them. ## Running tests @@ -121,12 +138,14 @@ bunx playwright test (unlike the `bun test` section above): throwaway Postgres/Redis/MinIO containers (`mcmapper-e2e-*`, distinct names/ports from the `bun test` ones so both can run at once), migrations, a seeded server + linked account/session + a 5x5-chunk terrain footprint around the -world origin, then the `api`/`frontend`/proxy processes. `global-teardown.ts` kills every spawned -process and removes the containers afterward. Covers the two UI flows most worth a real -click-through: the marker click-to-place/edit popup (`tests/markers.spec.ts`, including that a -marker created while linked shows up in a second browser context with the same session — the -cross-device sync claim) and the region-select drag + glTF export (`tests/region-export.spec.ts`, -including a real triggered file download). +world origin, then the `api`/`frontend`/proxy processes (the `api` process is started with a +fixed `MCMAPPER_ADMIN_TOKEN` for `tests/admin.spec.ts` to use — see `config.ts`'s `ADMIN_TOKEN`). +`global-teardown.ts` kills every spawned process and removes the containers afterward. Covers the +UI flows most worth a real click-through: the marker click-to-place/edit popup +(`tests/markers.spec.ts`, including that a marker created while linked shows up in a second +browser context with the same session — the cross-device sync claim), the region-select drag + +glTF export (`tests/region-export.spec.ts`, including a real triggered file download), and the +admin panel's token gate + server register/edit/delete round trip (`tests/admin.spec.ts`). ## Attribution diff --git a/api/.env.example b/api/.env.example index 9653897..26a27e1 100644 --- a/api/.env.example +++ b/api/.env.example @@ -15,7 +15,14 @@ MINIO_USE_SSL=false MINIO_ACCESS_KEY=mcmapper MINIO_SECRET_KEY=changeme-set-in-untracked-env -# Only read by `bun run seed` (see scripts/seed-server.ts) — Phase 6 replaces this with a real -# admin registration API. Point the mod's MapperConfig#serverToken at the same value. +# Only read by `bun run seed` (see scripts/seed-server.ts) — the admin panel's "register a new +# server" form (MCMAPPER_ADMIN_TOKEN below) is now the real registration path; this script is +# kept around as a fallback for scripted/headless setup. MCMAPPER_SEED_SERVER_NAME=dev-server MCMAPPER_SEED_SERVER_TOKEN=changeme + +# Gates every /api/admin/* route (server registry + settings — see api/src/admin.ts) behind a +# single shared secret, checked via the X-MCMapper-Admin-Token header. Left unset here on +# purpose: an unset token disables admin routes entirely (401) rather than defaulting to open. +# Set a real value in an untracked `.env` next to this file, same as MINIO_SECRET_KEY above. +MCMAPPER_ADMIN_TOKEN= diff --git a/api/src/admin.test.ts b/api/src/admin.test.ts new file mode 100644 index 0000000..2a00571 --- /dev/null +++ b/api/src/admin.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { isAdminTokenValid, listServers, registerServer, updateServerSettings, deleteServer } from "./admin"; +import { deleteTestServer } from "./test-helpers"; + +describe("isAdminTokenValid", () => { + const original = process.env.MCMAPPER_ADMIN_TOKEN; + afterEach(() => { + if (original === undefined) delete process.env.MCMAPPER_ADMIN_TOKEN; + else process.env.MCMAPPER_ADMIN_TOKEN = original; + }); + + test("rejects everything when MCMAPPER_ADMIN_TOKEN is unset — admin routes are disabled by default, not open", () => { + delete process.env.MCMAPPER_ADMIN_TOKEN; + expect(isAdminTokenValid("anything")).toBe(false); + expect(isAdminTokenValid(undefined)).toBe(false); + }); + + test("rejects a wrong token", () => { + process.env.MCMAPPER_ADMIN_TOKEN = "correct-token"; + expect(isAdminTokenValid("wrong-token")).toBe(false); + expect(isAdminTokenValid(undefined)).toBe(false); + }); + + test("accepts the configured token", () => { + process.env.MCMAPPER_ADMIN_TOKEN = "correct-token"; + expect(isAdminTokenValid("correct-token")).toBe(true); + }); +}); + +describe("registerServer", () => { + let createdId: string | undefined; + afterEach(async () => { + if (createdId) await deleteTestServer(createdId); + createdId = undefined; + }); + + test("creates a server with a generated token", async () => { + const result = await registerServer("admin-test-server", "offline"); + expect(result.ok).toBe(true); + if (!result.ok) return; + createdId = result.server.id; + expect(result.server.name).toBe("admin-test-server"); + expect(result.server.authMode).toBe("offline"); + expect(result.server.token.length).toBeGreaterThan(10); + }); + + test("rejects an empty name", async () => { + const result = await registerServer(" ", "offline"); + expect(result).toEqual({ ok: false, error: "missing_name" }); + }); + + test("rejects an invalid authMode", async () => { + const result = await registerServer("admin-test-bad-mode", "not-a-mode"); + expect(result).toEqual({ ok: false, error: "invalid_auth_mode" }); + }); +}); + +describe("listServers", () => { + let createdId: string | undefined; + afterEach(async () => { + if (createdId) await deleteTestServer(createdId); + createdId = undefined; + }); + + test("includes newly registered servers, with their token visible (admin-only view)", async () => { + const created = await registerServer("admin-test-list", "offline"); + if (!created.ok) throw new Error("setup failed"); + createdId = created.server.id; + + const servers = await listServers(); + const found = servers.find((s) => s.id === createdId); + expect(found).toBeDefined(); + expect(found!.token).toBe(created.server.token); + }); +}); + +describe("updateServerSettings", () => { + let createdId: string | undefined; + afterEach(async () => { + if (createdId) await deleteTestServer(createdId); + createdId = undefined; + }); + + test("updates the given fields and leaves the rest untouched", async () => { + const created = await registerServer("admin-test-update", "offline"); + if (!created.ok) throw new Error("setup failed"); + createdId = created.server.id; + + const result = await updateServerSettings(createdId, { + anonymousChatAllowed: false, + waypointFormat: "xaero", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.server.name).toBe("admin-test-update"); // untouched + expect(result.server.anonymousChatAllowed).toBe(false); + expect(result.server.waypointFormat).toBe("xaero"); + }); + + test("rejects an invalid waypointFormat", async () => { + const created = await registerServer("admin-test-bad-waypoint", "offline"); + if (!created.ok) throw new Error("setup failed"); + createdId = created.server.id; + + const result = await updateServerSettings(createdId, { waypointFormat: "not-a-format" }); + expect(result).toEqual({ ok: false, error: "invalid_waypoint_format" }); + }); + + test("returns not_found for a nonexistent server", async () => { + const result = await updateServerSettings(crypto.randomUUID(), { name: "x" }); + expect(result).toEqual({ ok: false, error: "not_found" }); + }); +}); + +describe("deleteServer", () => { + test("removes the server row", async () => { + const created = await registerServer("admin-test-delete", "offline"); + if (!created.ok) throw new Error("setup failed"); + + const result = await deleteServer(created.server.id); + expect(result.ok).toBe(true); + + const servers = await listServers(); + expect(servers.find((s) => s.id === created.server.id)).toBeUndefined(); + }); + + test("returns ok:false for a nonexistent server", async () => { + const result = await deleteServer(crypto.randomUUID()); + expect(result.ok).toBe(false); + }); +}); diff --git a/api/src/admin.ts b/api/src/admin.ts new file mode 100644 index 0000000..41e6b60 --- /dev/null +++ b/api/src/admin.ts @@ -0,0 +1,73 @@ +import { eq } from "drizzle-orm"; +import { db } from "./db/client"; +import { servers } from "./db/schema"; + +const VALID_AUTH_MODES = ["online", "offline"]; +const VALID_WAYPOINT_FORMATS = ["journeymap", "xaero", "off"]; + +/** + * Admin routes are gated on a single shared secret (`MCMAPPER_ADMIN_TOKEN`), not a per-account + * role — this is a single-operator hobby-project backend, and every other auth concern in this + * codebase already avoids cookies in favor of a bearer-style header (see link.ts's doc comment), + * so this follows the same shape. Unset means disabled, not "open" — there's no sensible default + * admin token to fall back to. + */ +export function isAdminTokenValid(token: string | undefined | null): boolean { + const expected = process.env.MCMAPPER_ADMIN_TOKEN; + if (!expected) return false; + return token === expected; +} + +export async function listServers() { + return db.select().from(servers); +} + +export type RegisterServerResult = + | { ok: true; server: typeof servers.$inferSelect } + | { ok: false; error: string }; + +/** + * Replaces the Phase 1 `bun run seed` env-var flow (scripts/seed-server.ts) as the real + * registration path — the token is generated here, never admin-supplied, so it can't collide + * with or be guessed from anything else. + */ +export async function registerServer(name: string, authMode: string): Promise { + if (!name.trim()) return { ok: false, error: "missing_name" }; + if (!VALID_AUTH_MODES.includes(authMode)) return { ok: false, error: "invalid_auth_mode" }; + + const token = `mcmapper-${crypto.randomUUID()}`; + const [server] = await db.insert(servers).values({ name: name.trim(), authMode, token }).returning(); + return { ok: true, server: server! }; +} + +export type UpdateServerSettingsInput = Partial<{ + name: string; + authMode: string; + anonymousChatAllowed: boolean; + waypointFormat: string; +}>; + +export type UpdateServerSettingsResult = + | { ok: true; server: typeof servers.$inferSelect } + | { ok: false; error: string }; + +export async function updateServerSettings( + id: string, + input: UpdateServerSettingsInput, +): Promise { + if (input.authMode !== undefined && !VALID_AUTH_MODES.includes(input.authMode)) { + return { ok: false, error: "invalid_auth_mode" }; + } + if (input.waypointFormat !== undefined && !VALID_WAYPOINT_FORMATS.includes(input.waypointFormat)) { + return { ok: false, error: "invalid_waypoint_format" }; + } + + const [server] = await db.update(servers).set(input).where(eq(servers.id, id)).returning(); + if (!server) return { ok: false, error: "not_found" }; + return { ok: true, server }; +} + +export async function deleteServer(id: string): Promise<{ ok: boolean }> { + const deleted = await db.delete(servers).where(eq(servers.id, id)).returning({ id: servers.id }); + return { ok: deleted.length > 0 }; +} diff --git a/api/src/index.test.ts b/api/src/index.test.ts index 00e6d62..3ec6aa0 100644 --- a/api/src/index.test.ts +++ b/api/src/index.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { describe, test, expect, beforeAll, afterAll, afterEach } from "bun:test"; import { app } from "./index"; import { db } from "./db/client"; import { meshPointers, tilePointers } from "./db/schema"; @@ -350,3 +350,88 @@ describe("GET /api/export/:serverId/:dimension/:x1/:z1/:x2/:z2", () => { expect((await res.json()) as any).toEqual({ ok: false, error: "selection_too_large" }); }); }); + +describe("/api/admin/servers", () => { + const ADMIN_TOKEN = "test-admin-token"; + let previousAdminToken: string | undefined; + let createdId: string | undefined; + + beforeAll(() => { + previousAdminToken = process.env.MCMAPPER_ADMIN_TOKEN; + process.env.MCMAPPER_ADMIN_TOKEN = ADMIN_TOKEN; + }); + + afterAll(() => { + if (previousAdminToken === undefined) delete process.env.MCMAPPER_ADMIN_TOKEN; + else process.env.MCMAPPER_ADMIN_TOKEN = previousAdminToken; + }); + + afterEach(async () => { + if (createdId) { + await deleteTestServer(createdId); + createdId = undefined; + } + }); + + test("GET rejects a missing or wrong admin token", async () => { + const noHeader = await get("/api/admin/servers"); + expect(noHeader.status).toBe(401); + + const wrongHeader = await app.handle( + new Request("http://localhost/api/admin/servers", { headers: { "X-MCMapper-Admin-Token": "nope" } }), + ); + expect(wrongHeader.status).toBe(401); + }); + + test("POST registers a server and returns its token; GET then lists it", async () => { + const registerRes = await post( + "/api/admin/servers", + { name: "admin-route-test", authMode: "offline" }, + { "X-MCMapper-Admin-Token": ADMIN_TOKEN }, + ); + expect(registerRes.status).toBe(200); + const registerBody = (await registerRes.json()) as any; + expect(registerBody.ok).toBe(true); + expect(registerBody.server.token).toBeTruthy(); + createdId = registerBody.server.id; + + const listRes = await app.handle( + new Request("http://localhost/api/admin/servers", { headers: { "X-MCMapper-Admin-Token": ADMIN_TOKEN } }), + ); + const list = (await listRes.json()) as any[]; + expect(list.some((s) => s.id === createdId && s.token === registerBody.server.token)).toBe(true); + }); + + test("POST 400s an invalid registration", async () => { + const res = await post("/api/admin/servers", { name: "", authMode: "offline" }, { "X-MCMapper-Admin-Token": ADMIN_TOKEN }); + expect(res.status).toBe(400); + }); + + test("PATCH updates settings and DELETE removes the server", async () => { + const registerRes = await post( + "/api/admin/servers", + { name: "admin-route-patch-delete", authMode: "offline" }, + { "X-MCMapper-Admin-Token": ADMIN_TOKEN }, + ); + const { server } = (await registerRes.json()) as any; + + const patchRes = await patch( + `/api/admin/servers/${server.id}`, + { anonymousChatAllowed: false, waypointFormat: "xaero" }, + { "X-MCMapper-Admin-Token": ADMIN_TOKEN }, + ); + expect(patchRes.status).toBe(200); + const patched = (await patchRes.json()) as any; + expect(patched.server.anonymousChatAllowed).toBe(false); + expect(patched.server.waypointFormat).toBe("xaero"); + + const deleteRes = await del(`/api/admin/servers/${server.id}`, { "X-MCMapper-Admin-Token": ADMIN_TOKEN }); + expect(deleteRes.status).toBe(200); + + const listRes = await app.handle( + new Request("http://localhost/api/admin/servers", { headers: { "X-MCMapper-Admin-Token": ADMIN_TOKEN } }), + ); + const list = (await listRes.json()) as any[]; + expect(list.some((s) => s.id === server.id)).toBe(false); + }); +}); diff --git a/api/src/index.ts b/api/src/index.ts index f26fd2e..9498be8 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -8,6 +8,7 @@ import { minio, TILE_BUCKET, ensureTileBucket } from "./minio"; import { redeemLinkCode, getAccountForSession, revokeSession } from "./link"; import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers"; import { getExportSections } from "./export"; +import { isAdminTokenValid, listServers as listServersAdmin, registerServer, updateServerSettings, deleteServer } from "./admin"; const MARKER_SHARE_ERROR_STATUS: Record = { not_found: 404, @@ -229,6 +230,54 @@ export const app = new Elysia() } return result; }) + // Admin: a single shared secret (MCMAPPER_ADMIN_TOKEN), not a per-account role — see + // admin.ts's doc comment. Replaces the Phase 1 `bun run seed` env-var flow as the real + // server-registration path. + .get("/api/admin/servers", async ({ headers, set }) => { + if (!isAdminTokenValid(headers["x-mcmapper-admin-token"])) { + set.status = 401; + return { error: "unauthenticated" }; + } + return listServersAdmin(); + }) + .post("/api/admin/servers", async ({ body, headers, set }) => { + if (!isAdminTokenValid(headers["x-mcmapper-admin-token"])) { + set.status = 401; + return { error: "unauthenticated" }; + } + const { name, authMode } = body as { name?: string; authMode?: string }; + if (!name || !authMode) { + set.status = 400; + return { ok: false, error: "missing_fields" }; + } + const result = await registerServer(name, authMode); + if (!result.ok) set.status = 400; + return result; + }) + .patch("/api/admin/servers/:id", async ({ params, body, headers, set }) => { + if (!isAdminTokenValid(headers["x-mcmapper-admin-token"])) { + set.status = 401; + return { error: "unauthenticated" }; + } + const { name, authMode, anonymousChatAllowed, waypointFormat } = body as { + name?: string; + authMode?: string; + anonymousChatAllowed?: boolean; + waypointFormat?: string; + }; + const result = await updateServerSettings(params.id, { name, authMode, anonymousChatAllowed, waypointFormat }); + if (!result.ok) set.status = result.error === "not_found" ? 404 : 400; + return result; + }) + .delete("/api/admin/servers/:id", async ({ params, headers, set }) => { + if (!isAdminTokenValid(headers["x-mcmapper-admin-token"])) { + set.status = 401; + return { error: "unauthenticated" }; + } + const result = await deleteServer(params.id); + if (!result.ok) set.status = 404; + return result; + }) .ws("/ws", { open: wsGateway.open, message: wsGateway.message, diff --git a/e2e/config.ts b/e2e/config.ts index 56e57c6..37f62f1 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -31,6 +31,10 @@ export const MINIO_SECRET_KEY = "mcmapper-e2e-only"; export const PROXY_ORIGIN = `http://localhost:${PROXY_PORT}`; +// Fixed, not random: admin.spec.ts needs to know it up front, and this only ever gates the +// throwaway e2e api instance. +export const ADMIN_TOKEN = "e2e-admin-token"; + export const STATE_FILE = join(import.meta.dirname, ".e2e-state.json"); export type E2eState = { @@ -41,6 +45,7 @@ export type E2eState = { serverToken: string; sessionToken: string; accountId: string; + adminToken: string; }; // Read by spec files — by the time tests run, global-setup.ts has already written this. diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 4c6df9b..f02a9ea 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -12,6 +12,7 @@ import { execSync, spawn, type ChildProcess } from "node:child_process"; import { writeFileSync } from "node:fs"; import postgres from "postgres"; import { + ADMIN_TOKEN, API_DIR, API_PORT, DATABASE_URL, @@ -145,6 +146,7 @@ export default async function globalSetup() { MINIO_USE_SSL: "false", MINIO_ACCESS_KEY, MINIO_SECRET_KEY, + MCMAPPER_ADMIN_TOKEN: ADMIN_TOKEN, }); await waitFor(() => httpOk(`http://localhost:${API_PORT}/health`), "api"); @@ -171,6 +173,7 @@ export default async function globalSetup() { serverToken, sessionToken, accountId: account!.id, + adminToken: ADMIN_TOKEN, }), ); diff --git a/e2e/tests/admin.spec.ts b/e2e/tests/admin.spec.ts new file mode 100644 index 0000000..98a824f --- /dev/null +++ b/e2e/tests/admin.spec.ts @@ -0,0 +1,75 @@ +// Covers the admin panel's token-unlock flow and server registry management (Phase 6) — the +// api-level admin.ts/index.ts routes are already unit/integration tested, so this focuses on the +// browser-side glue: the token gate, and register/edit/delete round-tripping through the real UI. +import { test, expect } from "@playwright/test"; +import { readE2eState } from "../config"; + +const state = readE2eState(); + +test.describe("admin panel", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("wrong token shows an error and does not unlock", async ({ page }) => { + await page.goto("/admin"); + await page.getByTestId("admin-token-input").fill("not-the-right-token"); + await page.getByTestId("admin-unlock").click(); + await expect(page.getByTestId("admin-error")).toBeVisible(); + await expect(page.getByTestId("admin-token-input")).toBeVisible(); + }); + + test("correct token unlocks and lists the seeded e2e server", async ({ page }) => { + await page.goto("/admin"); + await page.getByTestId("admin-token-input").fill(state.adminToken); + await page.getByTestId("admin-unlock").click(); + await expect(page.getByTestId("admin-lock")).toBeVisible(); + await expect(page.locator('[data-testid="admin-server-row"][data-server-name="e2e-server"]')).toBeVisible(); + }); + + test("registering a new server adds it to the list with a generated token", async ({ page }) => { + await page.goto("/admin"); + await page.getByTestId("admin-token-input").fill(state.adminToken); + await page.getByTestId("admin-unlock").click(); + + await page.getByTestId("admin-new-name").fill("e2e-admin-created"); + await page.getByTestId("admin-new-authmode").selectOption("online"); + await page.getByTestId("admin-register-submit").click(); + + const row = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-admin-created"]'); + await expect(row).toBeVisible(); + await expect(row.getByTestId("admin-server-token")).not.toHaveText(""); + }); + + test("editing settings and saving persists across a reload", async ({ page }) => { + await page.goto("/admin"); + await page.getByTestId("admin-token-input").fill(state.adminToken); + await page.getByTestId("admin-unlock").click(); + + const row = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-server"]'); + await row.getByTestId("admin-server-waypointformat").selectOption("xaero"); + await row.getByTestId("admin-server-anonchat").uncheck(); + await row.getByTestId("admin-server-save").click(); + await expect(row.getByTestId("admin-server-status")).toHaveText("saved"); + + // The admin token is remembered in localStorage (see admin.js's init()), so a reload + // auto-unlocks again rather than showing the token prompt. + await page.reload(); + const reloadedRow = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-server"]'); + await expect(reloadedRow.getByTestId("admin-server-waypointformat")).toHaveValue("xaero"); + await expect(reloadedRow.getByTestId("admin-server-anonchat")).not.toBeChecked(); + }); + + test("deleting a server removes it from the list", async ({ page }) => { + await page.goto("/admin"); + await page.getByTestId("admin-token-input").fill(state.adminToken); + await page.getByTestId("admin-unlock").click(); + + await page.getByTestId("admin-new-name").fill("e2e-admin-deleteme"); + await page.getByTestId("admin-new-authmode").selectOption("offline"); + await page.getByTestId("admin-register-submit").click(); + const row = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-admin-deleteme"]'); + await expect(row).toBeVisible(); + + await row.getByTestId("admin-server-delete").click(); + await expect(row).toHaveCount(0); + }); +}); diff --git a/frontend/src/index.ts b/frontend/src/index.ts index f6c281c..7281ff3 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -6,13 +6,16 @@ import { join } from "path"; // viewer (see public/js/mesh.js). Chat box, marker tool, and admin panel land in later phases. const renderIndex = pug.compileFile(join(import.meta.dir, "views/index.pug")); const renderScene3d = pug.compileFile(join(import.meta.dir, "views/scene3d.pug")); +const renderAdmin = pug.compileFile(join(import.meta.dir, "views/admin.pug")); const app = new Elysia() .get("/", () => new Response(renderIndex({}), { headers: { "Content-Type": "text/html" } })) .get("/3d", () => new Response(renderScene3d({}), { headers: { "Content-Type": "text/html" } })) + .get("/admin", () => new Response(renderAdmin({}), { headers: { "Content-Type": "text/html" } })) .get("/health", () => ({ status: "ok" })) .get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css"))) .get("/js/map.js", () => Bun.file(join(import.meta.dir, "public/js/map.js"))) + .get("/js/admin.js", () => Bun.file(join(import.meta.dir, "public/js/admin.js"))) .get("/js/coords.js", () => Bun.file(join(import.meta.dir, "public/js/coords.js"))) .get("/js/colors.js", () => Bun.file(join(import.meta.dir, "public/js/colors.js"))) .get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js"))) diff --git a/frontend/src/public/js/admin.js b/frontend/src/public/js/admin.js new file mode 100644 index 0000000..f8f2cf5 --- /dev/null +++ b/frontend/src/public/js/admin.js @@ -0,0 +1,98 @@ +// Admin panel (Phase 6): a single shared token gates /api/admin/* (see api/src/admin.ts's doc +// comment) — there's no per-account admin role, so this page just asks for that token once and +// keeps it in localStorage for the browser session, sending it back via the +// X-MCMapper-Admin-Token header on every admin request (same bearer-header pattern as the +// player-facing X-MCMapper-Session, see map.js). +const ADMIN_TOKEN_STORAGE_KEY = "mcmapper_admin_token"; + +function adminpanel() { + return { + tokenInput: "", + token: localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY) || null, + unlocked: false, + error: "", + loading: true, + servers: [], + newServer: { name: "", authMode: "offline" }, + registerStatus: "", + + async init() { + if (this.token) await this.tryLoad(); + }, + + async tryLoad() { + const res = await fetch("/api/admin/servers", { headers: { "X-MCMapper-Admin-Token": this.token } }); + if (!res.ok) { + this.error = res.status === 401 ? "invalid admin token" : `error loading servers (${res.status})`; + this.unlocked = false; + this.token = null; + localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); + this.loading = false; + return; + } + this.servers = await res.json(); + this.unlocked = true; + this.error = ""; + this.loading = false; + }, + + async unlock() { + this.token = this.tokenInput; + localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, this.token); + await this.tryLoad(); + }, + + lock() { + this.token = null; + this.unlocked = false; + this.servers = []; + this.tokenInput = ""; + localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); + }, + + async saveServer(server) { + server.status = "saving…"; + const res = await fetch(`/api/admin/servers/${server.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", "X-MCMapper-Admin-Token": this.token }, + body: JSON.stringify({ + name: server.name, + authMode: server.authMode, + anonymousChatAllowed: server.anonymousChatAllowed, + waypointFormat: server.waypointFormat, + }), + }); + server.status = res.ok ? "saved" : "save failed"; + }, + + async removeServer(server) { + const res = await fetch(`/api/admin/servers/${server.id}`, { + method: "DELETE", + headers: { "X-MCMapper-Admin-Token": this.token }, + }); + if (res.ok) this.servers = this.servers.filter((s) => s.id !== server.id); + }, + + async registerServer() { + if (!this.newServer.name.trim()) { + this.registerStatus = "name is required"; + return; + } + const res = await fetch("/api/admin/servers", { + method: "POST", + headers: { "Content-Type": "application/json", "X-MCMapper-Admin-Token": this.token }, + body: JSON.stringify({ name: this.newServer.name, authMode: this.newServer.authMode }), + }); + const result = await res.json(); + if (!result.ok) { + this.registerStatus = `failed: ${result.error}`; + return; + } + this.servers.push(result.server); + this.newServer = { name: "", authMode: "offline" }; + this.registerStatus = ""; + }, + }; +} + +window.adminpanel = adminpanel; diff --git a/frontend/src/views/admin.pug b/frontend/src/views/admin.pug new file mode 100644 index 0000000..4b6851c --- /dev/null +++ b/frontend/src/views/admin.pug @@ -0,0 +1,69 @@ +doctype html +html(lang="en") + head + meta(charset="utf-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + title MCMapper — Admin + link(rel="stylesheet" href="/css/tailwind.css") + script(type="module" src="/js/admin.js") + script(defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js") + body.bg-neutral-900.text-neutral-100.min-h-screen + div.max-w-3xl.mx-auto.p-6.space-y-6(x-data="adminpanel()" x-init="init()") + header.flex.items-center.gap-4 + h1.text-lg.font-semibold Server admin + a.text-sm.text-neutral-400.underline(href="/") map + + template(x-if="!unlocked") + div.space-y-2.max-w-sm + p.text-sm.text-neutral-400 Enter the admin token (MCMAPPER_ADMIN_TOKEN on the api service) to manage servers. + input.w-full.bg-neutral-800.text-sm.px-2.py-1.rounded.border.border-neutral-700( + type="password" placeholder="admin token" x-model="tokenInput" + x-on:keydown.enter="unlock" data-testid="admin-token-input") + button.px-3.py-1.bg-emerald-700.rounded.text-sm(x-on:click="unlock" data-testid="admin-unlock") Unlock + p.text-xs.text-red-400(x-show="error" x-text="error" data-testid="admin-error") + + template(x-if="unlocked") + div.space-y-6 + div.flex.items-center.justify-between + p.text-xs.text-neutral-500 Unlocked for this browser session. + button.text-xs.underline(x-on:click="lock" data-testid="admin-lock") lock + + section.space-y-2 + h2.text-sm.font-semibold Servers + div.space-y-2 + template(x-for="server in servers" x-bind:key="server.id") + div.bg-neutral-800.rounded.p-3.space-y-2(data-testid="admin-server-row" x-bind:data-server-name="server.name") + div.flex.items-center.justify-between.gap-2 + input.flex-1.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700( + type="text" x-model="server.name" data-testid="admin-server-name") + span.text-xs.text-neutral-500.font-mono.truncate(style="max-width: 14rem;" x-text="server.token" data-testid="admin-server-token") + div.grid.grid-cols-3.gap-2 + select.bg-neutral-900.text-xs.px-2.py-1.rounded.border.border-neutral-700( + x-model="server.authMode" data-testid="admin-server-authmode") + option(value="online") online + option(value="offline") offline + select.bg-neutral-900.text-xs.px-2.py-1.rounded.border.border-neutral-700( + x-model="server.waypointFormat" data-testid="admin-server-waypointformat") + option(value="journeymap") journeymap + option(value="xaero") xaero + option(value="off") off + label.flex.items-center.gap-1.text-xs.text-neutral-300 + input(type="checkbox" x-model="server.anonymousChatAllowed" data-testid="admin-server-anonchat") + | anonymous chat + div.flex.gap-2 + button.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="saveServer(server)" data-testid="admin-server-save") Save + button.px-2.py-1.bg-red-800.rounded.text-xs(x-on:click="removeServer(server)" data-testid="admin-server-delete") Delete + p.text-xs.text-amber-400(x-show="server.status" x-text="server.status" data-testid="admin-server-status") + p.text-sm.text-neutral-500(x-show="!loading && servers.length === 0") No servers registered yet. + + section.space-y-2.border-t.border-neutral-700.pt-4 + h2.text-sm.font-semibold Register a new server + div.flex.gap-2.flex-wrap + input.flex-1.bg-neutral-800.text-sm.px-2.py-1.rounded.border.border-neutral-700( + type="text" placeholder="server name" x-model="newServer.name" data-testid="admin-new-name") + select.bg-neutral-800.text-sm.px-2.py-1.rounded.border.border-neutral-700( + x-model="newServer.authMode" data-testid="admin-new-authmode") + option(value="online") online + option(value="offline") offline + button.px-3.py-1.bg-emerald-700.rounded.text-sm(x-on:click="registerServer" data-testid="admin-register-submit") Register + p.text-xs.text-neutral-400(x-show="registerStatus" x-text="registerStatus" data-testid="admin-register-status") diff --git a/frontend/src/views/index.pug b/frontend/src/views/index.pug index 94b01ea..61d1815 100644 --- a/frontend/src/views/index.pug +++ b/frontend/src/views/index.pug @@ -22,6 +22,7 @@ html(lang="en") header.px-4.py-2.flex.items-center.gap-4.border-b.border-neutral-700 h1.text-lg.font-semibold MCMapper a.text-sm.text-neutral-400.underline(href="/3d") 3D view + a.text-sm.text-neutral-400.underline(href="/admin") admin span.text-sm.text-neutral-400(x-show="!loading && server") | Viewing: span(x-text="server?.name")