feat(panel): player management

This commit is contained in:
Lemon-miaow committed 2026-07-02 03:30:50 +08:00
1 parent 8f41a003b0
commit 15c58d982d
19 files changed
+2282 -7

No files matched your search

+129
View File
@@ -195,3 +195,132 @@ describe("api.fleet wire shape", () => {
expect(await api.fleet()).toEqual([]);
});
});
// Pin the §access wire shapes (handlers_access.go). The panel translates structured
// fields into the request body — the backend re-validates and concatenates the RCON
// command, so the {action, player} keys and the GET-vs-POST split on the same path
// are the contract. A method/path/key drift here is invisible to typecheck (the body
// is `unknown`), so only these assertions catch it.
describe("api access-control wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("accessWhitelistList GETs the whitelist path and returns players + raw output", async () => {
const fetchSpy = fakeFetch({
name: "survival",
players: ["alice", "bob"],
output: "There are 2 whitelisted player(s): alice, bob",
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.accessWhitelistList("survival");
expect(res.players).toEqual(["alice", "bob"]);
expect(res.output).toMatch(/alice, bob/);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/servers/survival/access/whitelist");
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("accessWhitelist POSTs {action, player} to the same path", async () => {
const fetchSpy = fakeFetch({
name: "survival",
action: "add",
player: "alice",
output: "[ok]",
});
vi.stubGlobal("fetch", fetchSpy);
await api.accessWhitelist("survival", "add", "alice");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/servers/survival/access/whitelist");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
action: "add",
player: "alice",
});
});
it("accessBan POSTs {action, player} to the ban path (no reason field)", async () => {
const fetchSpy = fakeFetch({
name: "survival",
action: "ban",
player: "griefer",
output: "[ok]",
});
vi.stubGlobal("fetch", fetchSpy);
await api.accessBan("survival", "ban", "griefer");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/servers/survival/access/ban");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
action: "ban",
player: "griefer",
});
});
it("accessBanList GETs the ban path and returns players + raw output", async () => {
const fetchSpy = fakeFetch({
name: "survival",
players: ["griefer", "spammer"],
output:
"There are 2 ban(s):\ngriefer was banned by Server: x\nspammer was banned by Server: y",
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.accessBanList("survival");
expect(res.players).toEqual(["griefer", "spammer"]);
expect(res.output).toMatch(/griefer was banned by/);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/servers/survival/access/ban");
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("accessPlayers GETs the players path and returns tally + names + raw output", async () => {
const fetchSpy = fakeFetch({
name: "survival",
online: 2,
max: 20,
players: ["alice", "bob"],
output: "There are 2 of a max of 20 players online: alice, bob",
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.accessPlayers("survival");
expect(res.online).toBe(2);
expect(res.max).toBe(20);
expect(res.players).toEqual(["alice", "bob"]);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/servers/survival/access/players");
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("accessKick POSTs {player} to the kick path (no action, no reason)", async () => {
const fetchSpy = fakeFetch({
name: "survival",
player: "griefer",
output: "[ok]",
});
vi.stubGlobal("fetch", fetchSpy);
await api.accessKick("survival", "griefer");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/servers/survival/access/kick");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
player: "griefer",
});
});
it("maps the access error codes to stable human copy", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ code: "not_running" })).toMatch(/running|wake/i);
expect(humanizeError({ code: "console_unavailable" })).toMatch(/console/i);
});
});
+51
View File
@@ -1,13 +1,18 @@
import type {
AccessResult,
ApiError,
BanlistResult,
CreateServerRequest,
FleetServer,
Identity,
KickResult,
LinkResult,
LinkStatus,
LoginResult,
PlayersResult,
ServerInfo,
WhitelistImage,
WhitelistResult,
} from "./types";
import { loadConfig } from "./config";
import i18next from "i18next";
@@ -106,6 +111,46 @@ export const api = {
sendCommand: (name: string, command: string) =>
request<{ output: string }>("POST", `/servers/${name}/command`, { command }),
// Access control (spec §7 access). The backend translates these STRUCTURED fields
// into RCON commands — every field is charset-validated server-side before it is
// concatenated, so there is no free-text injection surface. All are owner-or-admin
// gated and require the server to be Running (409 `not_running` otherwise), so the
// panel only exposes them on a running server. The reply's `output` is the raw RCON
// text, surfaced verbatim as confirmation.
/** accessWhitelistList reads the server's whitelist. This GET ALSO requires a
* Running server (the readiness gate covers the read, not just the writes), so
* callers must gate the fetch on phase === "Running". */
accessWhitelistList: (name: string) =>
request<WhitelistResult>("GET", `/servers/${name}/access/whitelist`),
accessWhitelist: (name: string, action: "add" | "remove", player: string) =>
request<AccessResult>("POST", `/servers/${name}/access/whitelist`, {
action,
player,
}),
/** accessBanList reads the server's ban list. Like accessWhitelistList this GET
* requires a Running server (the readiness gate covers the read too), so callers
* gate the fetch on phase === "Running". */
accessBanList: (name: string) =>
request<BanlistResult>("GET", `/servers/${name}/access/ban`),
accessBan: (name: string, action: "ban" | "pardon", player: string) =>
request<AccessResult>("POST", `/servers/${name}/access/ban`, {
action,
player,
}),
/** accessPlayers reads WHO is online (the only source of names — status carries
* the count alone). Like accessWhitelistList this GET requires a Running server,
* so callers gate the fetch on phase === "Running". */
accessPlayers: (name: string) =>
request<PlayersResult>("GET", `/servers/${name}/access/players`),
accessKick: (name: string, player: string) =>
request<KickResult>("POST", `/servers/${name}/access/kick`, { player }),
listImages: () =>
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
@@ -171,6 +216,12 @@ export function humanizeError(e: unknown): string {
return t("already_exists");
case "cooldown":
return t("cooldown");
// Access control (spec §7): the server must be Running for any RCON-backed
// access change; the panel gates on phase, but a stale phase can still race.
case "not_running":
return t("not_running");
case "console_unavailable":
return t("console_unavailable");
default:
if (err.status === 401) return t("session_expired");
if (err.status === 403) return t("forbidden");
+58
View File
@@ -34,6 +34,64 @@ export interface ServerInfo {
owned?: boolean;
}
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
* `players` is a BEST-EFFORT parse of the vanilla "whitelist list" reply done
* server-side (parseWhitelistOutput); `output` is the raw RCON text and is the
* ground truth — on a non-vanilla or localized server the parse may come back
* empty while `output` still names players, so the panel falls back to `output`
* rather than rendering a falsely-empty list. */
export interface WhitelistResult {
name: string;
players: string[];
output: string;
}
/** BanlistResult projects GET /servers/{name}/access/ban (spec §7 access). Same
* shape as WhitelistResult: `players` is a BEST-EFFORT parse of the vanilla
* "banlist" reply done server-side (parseBanlistOutput) and `output` is the raw
* RCON text — ground truth. A ban entry reads "<name> was banned by <src>: <reason>",
* so unlike the whitelist the parse anchors on the ban marker (a reason carries its
* own colon); on a non-vanilla or localized server the parse may come back empty
* while `output` still names players, so the panel falls back to `output`. */
export interface BanlistResult {
name: string;
players: string[];
output: string;
}
/** AccessResult is the common echo of a successful access mutation (whitelist add/
* remove, ban/pardon): the server replays the structured action it ran plus the
* raw RCON `output`, which the panel surfaces verbatim as confirmation. */
export interface AccessResult {
name: string;
action: string;
player: string;
output: string;
}
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
* ONLY source of WHO is online — ServerInfo.players carries the count alone.
* `online`/`max` are the tally; `players` is a BEST-EFFORT parse of the vanilla
* "list" reply (parseListOutput) and, like the whitelist, can come back empty on a
* non-vanilla format while `output` (the raw RCON text, ground truth) still names
* them. `online` can therefore be > `players.length` — show the count, fall back
* to `output` for names. */
export interface PlayersResult {
name: string;
online: number;
max: number;
players: string[];
output: string;
}
/** KickResult echoes a successful kick. Kick is a single verb (no add/remove), so
* unlike AccessResult it carries no `action` — just the player and raw reply. */
export interface KickResult {
name: string;
player: string;
output: string;
}
/** FleetServer is one row of GET /api/v1/fleet — the SysAdmin cockpit's fleet-wide
* read (admin-tier). It mirrors the Go fleetServerView: the CRD lifecycle
* projection plus the owner joined read-only from Postgres for display.