Add Phase 6: multi-server admin panel (server registry + settings)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
@@ -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")))
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user