feat(panel): player management
This commit is contained in:
19 files changed
+2282
-7
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user