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
+69
View File
@@ -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")
+1
View File
@@ -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")