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:
@@ -61,20 +61,37 @@ docker compose up
|
|||||||
migrations on startup (see `api/src/db/migrate.ts`); `worker` consumes the `mcmapper:dirty-chunks`
|
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.
|
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
|
Register a server via the admin panel at `/admin` (set `MCMAPPER_ADMIN_TOKEN` first — see
|
||||||
mod's `MapperConfig` at the same token:
|
"Admin panel" below), or seed one row by hand for scripted/headless setup:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose run --rm api bun run seed
|
docker compose run --rm api bun run seed
|
||||||
```
|
```
|
||||||
|
|
||||||
reads `MCMAPPER_SEED_SERVER_NAME`/`MCMAPPER_SEED_SERVER_TOKEN` from `api/.env.example` (edit
|
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,
|
those first, or override with `-e`). Either way, point the mod's `MapperConfig#serverToken` at
|
||||||
tiles appear at `GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY.png` (zoom is always `0`
|
the resulting token. Once the mod connects and sends its initial chunk backfill, tiles appear at
|
||||||
for now — see `worker/src/render/cpu.rs`) and the frontend's Leaflet viewer picks them up
|
`GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY.png` (zoom is always `0` for now — see
|
||||||
automatically from `GET /api/servers`.
|
`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
|
## Running tests
|
||||||
|
|
||||||
@@ -121,12 +138,14 @@ bunx playwright test
|
|||||||
(unlike the `bun test` section above): throwaway Postgres/Redis/MinIO containers
|
(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),
|
(`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
|
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
|
world origin, then the `api`/`frontend`/proxy processes (the `api` process is started with a
|
||||||
process and removes the containers afterward. Covers the two UI flows most worth a real
|
fixed `MCMAPPER_ADMIN_TOKEN` for `tests/admin.spec.ts` to use — see `config.ts`'s `ADMIN_TOKEN`).
|
||||||
click-through: the marker click-to-place/edit popup (`tests/markers.spec.ts`, including that a
|
`global-teardown.ts` kills every spawned process and removes the containers afterward. Covers the
|
||||||
marker created while linked shows up in a second browser context with the same session — the
|
UI flows most worth a real click-through: the marker click-to-place/edit popup
|
||||||
cross-device sync claim) and the region-select drag + glTF export (`tests/region-export.spec.ts`,
|
(`tests/markers.spec.ts`, including that a marker created while linked shows up in a second
|
||||||
including a real triggered file download).
|
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
|
## Attribution
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -15,7 +15,14 @@ MINIO_USE_SSL=false
|
|||||||
MINIO_ACCESS_KEY=mcmapper
|
MINIO_ACCESS_KEY=mcmapper
|
||||||
MINIO_SECRET_KEY=changeme-set-in-untracked-env
|
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
|
# Only read by `bun run seed` (see scripts/seed-server.ts) — the admin panel's "register a new
|
||||||
# admin registration API. Point the mod's MapperConfig#serverToken at the same value.
|
# 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_NAME=dev-server
|
||||||
MCMAPPER_SEED_SERVER_TOKEN=changeme
|
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=
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<RegisterServerResult> {
|
||||||
|
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<UpdateServerSettingsResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
+86
-1
@@ -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 { app } from "./index";
|
||||||
import { db } from "./db/client";
|
import { db } from "./db/client";
|
||||||
import { meshPointers, tilePointers } from "./db/schema";
|
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" });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
|
|||||||
import { redeemLinkCode, getAccountForSession, revokeSession } from "./link";
|
import { redeemLinkCode, getAccountForSession, revokeSession } from "./link";
|
||||||
import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers";
|
import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers";
|
||||||
import { getExportSections } from "./export";
|
import { getExportSections } from "./export";
|
||||||
|
import { isAdminTokenValid, listServers as listServersAdmin, registerServer, updateServerSettings, deleteServer } from "./admin";
|
||||||
|
|
||||||
const MARKER_SHARE_ERROR_STATUS: Record<string, number> = {
|
const MARKER_SHARE_ERROR_STATUS: Record<string, number> = {
|
||||||
not_found: 404,
|
not_found: 404,
|
||||||
@@ -229,6 +230,54 @@ export const app = new Elysia()
|
|||||||
}
|
}
|
||||||
return result;
|
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", {
|
.ws("/ws", {
|
||||||
open: wsGateway.open,
|
open: wsGateway.open,
|
||||||
message: wsGateway.message,
|
message: wsGateway.message,
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export const MINIO_SECRET_KEY = "mcmapper-e2e-only";
|
|||||||
|
|
||||||
export const PROXY_ORIGIN = `http://localhost:${PROXY_PORT}`;
|
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 const STATE_FILE = join(import.meta.dirname, ".e2e-state.json");
|
||||||
|
|
||||||
export type E2eState = {
|
export type E2eState = {
|
||||||
@@ -41,6 +45,7 @@ export type E2eState = {
|
|||||||
serverToken: string;
|
serverToken: string;
|
||||||
sessionToken: string;
|
sessionToken: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
adminToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read by spec files — by the time tests run, global-setup.ts has already written this.
|
// Read by spec files — by the time tests run, global-setup.ts has already written this.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { execSync, spawn, type ChildProcess } from "node:child_process";
|
|||||||
import { writeFileSync } from "node:fs";
|
import { writeFileSync } from "node:fs";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import {
|
import {
|
||||||
|
ADMIN_TOKEN,
|
||||||
API_DIR,
|
API_DIR,
|
||||||
API_PORT,
|
API_PORT,
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
@@ -145,6 +146,7 @@ export default async function globalSetup() {
|
|||||||
MINIO_USE_SSL: "false",
|
MINIO_USE_SSL: "false",
|
||||||
MINIO_ACCESS_KEY,
|
MINIO_ACCESS_KEY,
|
||||||
MINIO_SECRET_KEY,
|
MINIO_SECRET_KEY,
|
||||||
|
MCMAPPER_ADMIN_TOKEN: ADMIN_TOKEN,
|
||||||
});
|
});
|
||||||
await waitFor(() => httpOk(`http://localhost:${API_PORT}/health`), "api");
|
await waitFor(() => httpOk(`http://localhost:${API_PORT}/health`), "api");
|
||||||
|
|
||||||
@@ -171,6 +173,7 @@ export default async function globalSetup() {
|
|||||||
serverToken,
|
serverToken,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
accountId: account!.id,
|
accountId: account!.id,
|
||||||
|
adminToken: ADMIN_TOKEN,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
// 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 renderIndex = pug.compileFile(join(import.meta.dir, "views/index.pug"));
|
||||||
const renderScene3d = pug.compileFile(join(import.meta.dir, "views/scene3d.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()
|
const app = new Elysia()
|
||||||
.get("/", () => new Response(renderIndex({}), { headers: { "Content-Type": "text/html" } }))
|
.get("/", () => new Response(renderIndex({}), { headers: { "Content-Type": "text/html" } }))
|
||||||
.get("/3d", () => new Response(renderScene3d({}), { 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("/health", () => ({ status: "ok" }))
|
||||||
.get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css")))
|
.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/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/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/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")))
|
.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
|
header.px-4.py-2.flex.items-center.gap-4.border-b.border-neutral-700
|
||||||
h1.text-lg.font-semibold MCMapper
|
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="/3d") 3D view
|
||||||
|
a.text-sm.text-neutral-400.underline(href="/admin") admin
|
||||||
span.text-sm.text-neutral-400(x-show="!loading && server")
|
span.text-sm.text-neutral-400(x-show="!loading && server")
|
||||||
| Viewing:
|
| Viewing:
|
||||||
span(x-text="server?.name")
|
span(x-text="server?.name")
|
||||||
|
|||||||
Reference in New Issue
Block a user