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:
2026-08-09 13:06:37 +02:00
parent 7b85f4dff1
commit a134c47152
13 changed files with 634 additions and 16 deletions
+98
View File
@@ -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;