From 15c58d982dca42e67b0ad049eae5dcabbc65f773 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Wed, 1 Jul 2026 19:57:51 +0800 Subject: [PATCH] feat(panel): player management --- docs/openapi.yaml | 133 ++++++++ internal/api/api.go | 3 + internal/api/handlers_access.go | 130 ++++++++ internal/api/handlers_access_test.go | 214 +++++++++++++ panel/dev/mockApi.ts | 211 +++++++++++- panel/src/App.tsx | 2 + panel/src/components/players/BansSection.tsx | 290 +++++++++++++++++ .../src/components/players/OnlineSection.tsx | 238 ++++++++++++++ .../components/players/WhitelistSection.tsx | 241 ++++++++++++++ panel/src/components/players/shared.tsx | 299 ++++++++++++++++++ panel/src/i18n/resources/en-US/errors.json | 2 + panel/src/i18n/resources/en-US/servers.json | 57 +++- panel/src/i18n/resources/zh-CN/errors.json | 2 + panel/src/i18n/resources/zh-CN/servers.json | 57 +++- panel/src/lib/api.test.ts | 129 ++++++++ panel/src/lib/api.ts | 51 +++ panel/src/lib/types.ts | 58 ++++ panel/src/pages/ServerConsole.tsx | 20 +- panel/src/pages/ServerPlayers.tsx | 152 +++++++++ 19 files changed, 2282 insertions(+), 7 deletions(-) create mode 100644 panel/src/components/players/BansSection.tsx create mode 100644 panel/src/components/players/OnlineSection.tsx create mode 100644 panel/src/components/players/WhitelistSection.tsx create mode 100644 panel/src/components/players/shared.tsx create mode 100644 panel/src/pages/ServerPlayers.tsx diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 627d325..6ec33ff 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1069,7 +1069,88 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' + /api/v1/servers/{name}/access/players: + get: + tags: [access] + operationId: accessPlayers + summary: List online players via RCON (spec §7). Owner/admin only. + description: >- + Runs "list" against the live server and returns the online/max tally, a + best-effort parse of the online player names, and the raw reply. This is + the only source of WHO is online — Status.Players carries the count alone. + The RCON password is never accepted or returned (spec §286). + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + responses: + '200': + description: Online players. + content: + application/json: + schema: + type: object + required: [name, online, max, players, output] + properties: + name: { type: string } + online: { type: integer } + max: { type: integer } + players: { type: array, items: { type: string } } + output: { type: string } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: Server not running. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + /api/v1/servers/{name}/access/ban: + get: + tags: [access] + operationId: accessBanList + summary: List banned players via RCON (spec §7). Owner/admin only. + description: >- + Runs "banlist" against the live server and returns a best-effort parse + plus the raw reply. The RCON password is never accepted or returned + (spec §286). + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + responses: + '200': + description: Banned players. + content: + application/json: + schema: + type: object + required: [name, players, output] + properties: + name: { type: string } + players: { type: array, items: { type: string } } + output: { type: string } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: Server not running. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' post: tags: [access] operationId: accessBan @@ -1112,6 +1193,58 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' + /api/v1/servers/{name}/access/kick: + post: + tags: [access] + operationId: accessKick + summary: Kick a player off the running server (spec §7). Owner/admin only. + description: >- + Translates to the RCON "kick " command. Unlike ban it does not + block rejoining. Carries no reason field (a free-text reason would be an + injection vector; the audit log records intent). The RCON password is + never accepted or returned (spec §286). + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [player] + properties: + player: { type: string } + responses: + '200': + description: The player was kicked; the raw RCON reply is in output. + content: + application/json: + schema: + type: object + required: [name, player, output] + properties: + name: { type: string } + player: { type: string } + output: { type: string } + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: Server not running. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + /api/v1/servers/{name}/access/permission: post: tags: [access] diff --git a/internal/api/api.go b/internal/api/api.go index e351a23..178d9c1 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -317,7 +317,10 @@ func (a *API) externalAPIRoutes() []apiRoute { // strict-charset-validated structured fields (handlers_access.go). {Method: "POST", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelist}, {Method: "GET", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelistList}, + {Method: "GET", Pattern: "/api/v1/servers/{name}/access/players", h: a.handleAccessPlayers}, + {Method: "POST", Pattern: "/api/v1/servers/{name}/access/kick", h: a.handleAccessKick}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBan}, + {Method: "GET", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBanList}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/permission", h: a.handleAccessPermission}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup}, {Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus}, diff --git a/internal/api/handlers_access.go b/internal/api/handlers_access.go index 257ad83..b49e07f 100644 --- a/internal/api/handlers_access.go +++ b/internal/api/handlers_access.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" + "strconv" "strings" "felis.lolicon.best/internal/naming" @@ -179,6 +180,43 @@ func (a *API) handleAccessWhitelistList(w http.ResponseWriter, r *http.Request) }) } +// handleAccessPlayers is the read projector for the online roster: it runs the +// vanilla "list" command and returns the online/max tally, a best-effort parse of +// the online player names, and the raw reply. Like the whitelist read, the parse +// is vanilla-specific (the names arrive after the count line's colon) and the raw +// output is always returned so a differing format never loses information. This is +// the only place the panel can learn WHO is online — Status.Players carries the +// count alone (§141), so this reuses the same RCON reply the prober already sees. +func (a *API) handleAccessPlayers(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + out, ok := a.issueAccessCommand(w, r, name, "list") + if !ok { + return + } + online, max, players := parseListOutput(out) + writeJSON(w, http.StatusOK, map[string]any{ + "name": name, "online": online, "max": max, "players": players, "output": out, + }) +} + +// handleAccessBanList is the read projector for the ban list: it runs "banlist" +// and returns a best-effort parse of the banned names PLUS the raw reply, like the +// whitelist / players reads. Unlike them the parse cannot key on a colon tail — a +// ban entry reads " was banned by : " and the reason carries +// its own colon — so parseBanlistOutput anchors on the ban marker + name charset +// instead. The raw reply is always returned so a plugin or localised format never +// loses information. No audit (a read). +func (a *API) handleAccessBanList(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + out, ok := a.issueAccessCommand(w, r, name, "banlist") + if !ok { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "name": name, "players": parseBanlistOutput(out), "output": out, + }) +} + // banRequest is the body of POST .../access/ban (deny / restore a player's // ability to join, spec §7). It carries NO reason field on purpose: a free-text // reason would be the one place a structured request could splice a second RCON @@ -217,6 +255,39 @@ func (a *API) handleAccessBan(w http.ResponseWriter, r *http.Request) { }) } +// kickRequest is the body of POST .../access/kick (remove a player from the +// server right now, spec §7). Like ban it carries NO reason field — a free-text +// reason is the one place a structured request could splice a second RCON command, +// and it buys nothing the audit log does not already record. +type kickRequest struct { + Player string `json:"player"` +} + +// handleAccessKick kicks a player off the running server via "kick ". +// Unlike ban it does not block rejoining; it is the immediate "get out now" that +// pairs with the online roster. Single-action, so the body carries only a player. +func (a *API) handleAccessKick(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + var body kickRequest + if err := decodeJSON(w, r, &body); err != nil { + writeError(w, r, err) + return + } + if !mcNameRe.MatchString(body.Player) { + writeError(w, r, errInvalidPlayer) + return + } + + out, ok := a.issueAccessCommand(w, r, name, "kick "+body.Player) + if !ok { + return + } + a.audit(r, principalFromContext(r.Context()).Email, "access.kick", name) + writeJSON(w, http.StatusOK, map[string]any{ + "name": name, "player": body.Player, "output": out, + }) +} + // permissionRequest is the body of POST .../access/permission: a fine-grained // LuckPerms permission grant/deny on a single node, optionally scoped to a world // (spec §7 细致的权限调整 + world 范围). @@ -351,3 +422,62 @@ func parseWhitelistOutput(out string) []string { } return players } + +// listCountRe matches the count line of vanilla's "list" reply, e.g. +// "There are 3 of a max of 20 players online: alice, bob, carol". It mirrors the +// operator prober's listReplyPattern; kept local so the api package does not +// depend on operator internals for a read it already has the reply for. +var listCountRe = regexp.MustCompile(`There are (\d+) of a max of (\d+) players online`) + +// parseListOutput extracts (online, max, names) from vanilla's "list" reply. The +// count comes from the "N of a max of M" line; the names come from the tail after +// the colon, comma-separated (each name is [A-Za-z0-9_], so it never contains a +// colon or comma of its own). Best-effort and vanilla-specific — the raw reply is +// always returned alongside — so a plugin or localised format loses nothing. names +// is non-nil so the JSON renders [] not null; online/max are 0 when the count line +// does not match (e.g. an empty or unrecognised reply). +func parseListOutput(out string) (online, max int, players []string) { + players = []string{} + if i := strings.Index(out, ":"); i >= 0 { + for _, part := range strings.Split(out[i+1:], ",") { + if p := strings.TrimSpace(part); p != "" { + players = append(players, p) + } + } + } + if m := listCountRe.FindStringSubmatch(out); m != nil { + online, _ = strconv.Atoi(m[1]) + max, _ = strconv.Atoi(m[2]) + } + return online, max, players +} + +// banEntryRe matches one player-ban entry in vanilla's "banlist" reply, anchored on +// the " was banned by" marker with the name pinned to mcNameRe's charset. The +// anchoring is deliberate and NOT interchangeable with the whitelist/list tail +// parse: "banlist" emits one command-feedback message PER ban, and RCON concatenates +// them with a separator that is server/version-dependent (newline, space, or none), +// so a line- or colon-split parser could run the "There are N ban(s):" header into +// the first entry and emit a non-name. Keying only on the marker + name charset +// yields the SAME names under every separator and structurally cannot return a +// non-name (group 1 IS the charset), so a corrupt entry can never reach the one-tap +// pardon button. It also sidesteps the reason's own colon, which the tail parse can't. +// +// We issue plain "banlist", which in vanilla lists PLAYER bans only (IP bans are the +// separate "banlist ips", which nothing here ever issues), so a "1.2.3.4 was banned +// by ..." line — whose trailing octet the charset would otherwise capture as a bogus +// short name — never reaches this parser. +var banEntryRe = regexp.MustCompile(`([A-Za-z0-9_]{1,16}) was banned by`) + +// parseBanlistOutput extracts banned player names from vanilla's "banlist" reply, +// whose entries read " was banned by : ". Best-effort and +// vanilla-specific — the raw reply is always returned alongside — so a plugin or +// localised format loses nothing; the "There are no ban(s)." / header lines carry no +// marker and are skipped. Returns a non-nil empty slice so the JSON renders [] not null. +func parseBanlistOutput(out string) []string { + players := []string{} + for _, m := range banEntryRe.FindAllStringSubmatch(out, -1) { + players = append(players, m[1]) + } + return players +} diff --git a/internal/api/handlers_access_test.go b/internal/api/handlers_access_test.go index a61a239..63bc9be 100644 --- a/internal/api/handlers_access_test.go +++ b/internal/api/handlers_access_test.go @@ -44,6 +44,9 @@ func TestAccessTranslation(t *testing.T) { `{"action":"ban","player":"Griefer_99"}`, "ban Griefer_99", "access.ban.ban"}, {"pardon", "/api/v1/servers/survival/access/ban", `{"action":"pardon","player":"Griefer_99"}`, "pardon Griefer_99", "access.ban.pardon"}, + // kick has no action field — a single verb — so its label is "access.kick". + {"kick", "/api/v1/servers/survival/access/kick", + `{"player":"Griefer_99"}`, "kick Griefer_99", "access.kick"}, // The *bool trap: omitted value defaults to true (grant), NOT false (deny). {"permission set default grant", "/api/v1/servers/survival/access/permission", `{"action":"set","player":"Steve","node":"essentials.fly"}`, @@ -100,6 +103,8 @@ func TestAccessInjectionRejected(t *testing.T) { {"whitelist player newline", "/api/v1/servers/survival/access/whitelist", `{"action":"add","player":"ev\nop x"}`}, {"ban player semicolon", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev;il"}`}, {"ban player space", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev il"}`}, + {"kick player space", "/api/v1/servers/survival/access/kick", `{"player":"ev il"}`}, + {"kick player newline", "/api/v1/servers/survival/access/kick", `{"player":"ev\nop x"}`}, {"permission player space", "/api/v1/servers/survival/access/permission", `{"action":"set","player":"ev il","node":"essentials.fly"}`}, {"group player newline", "/api/v1/servers/survival/access/group", @@ -383,3 +388,212 @@ func TestParseWhitelistOutput(t *testing.T) { } } } + +// TestAccessPlayers exercises the online-roster read projector: GET runs "list" +// and returns the online/max tally, the parsed names, and the raw reply, owner- +// gated like the writes and never auditing. +func TestAccessPlayers(t *testing.T) { + t.Run("parses tally, names and raw output", func(t *testing.T) { + api, repo, _, console := mkAccess(t) + console.reply = "There are 3 of a max of 20 players online: alice, bob, carol" + api.External = staticExternal{p: accessOwner} + w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code = %d body %s", w.Code, w.Body.String()) + } + var resp struct { + Name string `json:"name"` + Online int `json:"online"` + Max int `json:"max"` + Players []string `json:"players"` + Output string `json:"output"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) + } + if resp.Name != "survival" || resp.Online != 3 || resp.Max != 20 || resp.Output != console.reply { + t.Fatalf("unexpected response %+v", resp) + } + if len(resp.Players) != 3 || resp.Players[0] != "alice" || resp.Players[2] != "carol" { + t.Fatalf("players = %#v, want [alice bob carol]", resp.Players) + } + if console.gotCommand != "list" { + t.Fatalf("console got %q, want %q", console.gotCommand, "list") + } + if len(repo.audits) != 0 { + t.Fatalf("GET players must not audit: %+v", repo.audits) + } + }) + + t.Run("empty server -> [] not null", func(t *testing.T) { + api, _, _, console := mkAccess(t) + console.reply = "There are 0 of a max of 20 players online:" + api.External = staticExternal{p: accessOwner} + w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code = %d body %s", w.Code, w.Body.String()) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("body not JSON: %v", err) + } + if string(raw["players"]) != "[]" { + t.Fatalf("players = %s, want []", raw["players"]) + } + }) + + t.Run("non-owner -> 403, no RCON call", func(t *testing.T) { + api, _, _, console := mkAccess(t) + api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}} + w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil) + if w.Code != http.StatusForbidden { + t.Fatalf("code = %d, want 403", w.Code) + } + if console.calls != 0 { + t.Fatal("a forbidden caller must not reach RCON") + } + }) +} + +// TestParseListOutput unit-tests the "list" parser directly, including formats +// issueAccessCommand never produces but a real server might. Names parse from the +// colon tail independently of the count line, so both are pinned separately. +func TestParseListOutput(t *testing.T) { + cases := []struct { + in string + online, max int + want []string + }{ + {"There are 3 of a max of 20 players online: alice, bob, carol", 3, 20, []string{"alice", "bob", "carol"}}, + {"There are 1 of a max of 20 players online: Steve", 1, 20, []string{"Steve"}}, + {"There are 0 of a max of 20 players online:", 0, 20, []string{}}, + {"There are 0 of a max of 20 players online", 0, 20, []string{}}, + {"", 0, 0, []string{}}, + // A colon tail with no recognised count line still yields names, tally 0. + {"Online: a, b ,c", 0, 0, []string{"a", "b", "c"}}, + } + for _, tc := range cases { + online, max, got := parseListOutput(tc.in) + if got == nil { + t.Fatalf("parseListOutput(%q) names = nil, want non-nil slice", tc.in) + } + if online != tc.online || max != tc.max { + t.Fatalf("parseListOutput(%q) = (%d,%d), want (%d,%d)", tc.in, online, max, tc.online, tc.max) + } + if len(got) != len(tc.want) { + t.Fatalf("parseListOutput(%q) names = %#v, want %#v", tc.in, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("parseListOutput(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i]) + } + } + } +} + +// TestAccessBanList exercises the ban-list read projector: GET runs "banlist", +// returns the parsed names plus the raw reply, is owner-gated, and never audits. +func TestAccessBanList(t *testing.T) { + t.Run("parses players and returns raw output", func(t *testing.T) { + api, repo, _, console := mkAccess(t) + console.reply = "There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam" + api.External = staticExternal{p: accessOwner} + w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code = %d body %s", w.Code, w.Body.String()) + } + var resp struct { + Name string `json:"name"` + Players []string `json:"players"` + Output string `json:"output"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) + } + if resp.Name != "survival" || resp.Output != console.reply { + t.Fatalf("unexpected response %+v", resp) + } + if len(resp.Players) != 2 || resp.Players[0] != "Steve" || resp.Players[1] != "Alex" { + t.Fatalf("players = %#v, want [Steve Alex]", resp.Players) + } + if console.gotCommand != "banlist" { + t.Fatalf("console got %q, want %q", console.gotCommand, "banlist") + } + if len(repo.audits) != 0 { + t.Fatalf("GET ban must not audit: %+v", repo.audits) + } + }) + + t.Run("empty ban list -> [] not null", func(t *testing.T) { + api, _, _, console := mkAccess(t) + console.reply = "There are no bans." + api.External = staticExternal{p: accessOwner} + w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code = %d body %s", w.Code, w.Body.String()) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("body not JSON: %v", err) + } + if string(raw["players"]) != "[]" { + t.Fatalf("players = %s, want []", raw["players"]) + } + }) + + t.Run("non-owner -> 403, no RCON call", func(t *testing.T) { + api, _, _, console := mkAccess(t) + api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}} + w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil) + if w.Code != http.StatusForbidden { + t.Fatalf("code = %d, want 403", w.Code) + } + if console.calls != 0 { + t.Fatal("a forbidden caller must not reach RCON") + } + }) +} + +// TestParseBanlistOutput unit-tests the ban parser directly. The critical cases are +// the SEPARATOR variants: "banlist" emits one feedback message per ban and RCON's +// concatenation separator is version-dependent, so the parser must return the same +// names whether entries are newline-, space-, or non-separated — and must never let +// the header or a reason's own colon/spaces leak into a name (a corrupt name would +// arm an off-charset pardon). +func TestParseBanlistOutput(t *testing.T) { + cases := []struct { + name string + in string + want []string + }{ + { + "newline-separated", + "There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam", + []string{"Steve", "Alex"}, + }, + { + // The separator the wire format might actually use: header + entries + // concatenated with spaces, reasons carrying spaces of their own. + "space-concatenated with spaced reasons", + "There are 2 ban(s): Steve was banned by Server: griefing spawn Alex was banned by Server: spam", + []string{"Steve", "Alex"}, + }, + {"single ban", "There are 1 ban(s):\nNotch was banned by Console: rude", []string{"Notch"}}, + {"no bans", "There are no bans.", []string{}}, + {"empty", "", []string{}}, + } + for _, tc := range cases { + got := parseBanlistOutput(tc.in) + if got == nil { + t.Fatalf("%s: parseBanlistOutput(%q) = nil, want non-nil slice", tc.name, tc.in) + } + if len(got) != len(tc.want) { + t.Fatalf("%s: parseBanlistOutput(%q) = %#v, want %#v", tc.name, tc.in, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("%s: parseBanlistOutput(%q)[%d] = %q, want %q", tc.name, tc.in, i, got[i], tc.want[i]) + } + } + } +} diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index e4ad8d9..07eaf8d 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -34,12 +34,29 @@ interface MockServer extends ServerInfo { owner: AccountID | null; } +interface AccessState { + whitelist: string[]; + banned: string[]; + // online is the mock's stand-in for the live RCON "list" roster. Kick and ban + // splice a player out of it so the demo roster reflects the action on reload. + online: string[]; +} + interface MockState { accounts: Record; servers: MockServer[]; images: WhitelistImage[]; + // Per-server §access state, keyed by server name. Lazily created (accessFor) so a + // server only gets an entry once its access is touched; "survival" is pre-seeded + // so the whitelist panel demos a populated list out of the box. + access: Record; } +// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock +// rejects a malformed player exactly as the real API would (400 bad_request), +// keeping the panel's error path exercisable in dev. +const PLAYER_NAME = /^[A-Za-z0-9_]{1,16}$/; + interface RequestContext { req: IncomingMessage; res: ServerResponse; @@ -132,7 +149,7 @@ function initialState(): MockState { ], servers: [ server("survival", "Survival SMP", "Running", "owner", { - players: 7, + players: 12, maxPlayers: 20, autostartPolicy: "public", }), @@ -158,6 +175,34 @@ function initialState(): MockState { }), ...generatedServers(), ], + access: { + // Seeded past a page (PAGE_SIZE=10) and the search threshold (>8) so the + // whitelist's paging + filter are both exercisable in the mock demo. + survival: { + whitelist: [ + "mock_player", "test_player", "Notch", "jeb_", "Dinnerbone", + "Grumm", "Steve", "Alex", "Herobrine", "Technoblade", + "Dream", "GeorgeNotFound", "Sapnap", "BadBoyHalo", "Skeppy", + "Tommyinnit", "Tubbo", "Ranboo", "Wilbur_Soot", "Philza", + "Captain_Puffy", "Nihachu", "Fundy", "Quackity", "Karl_Jacobs", + ], + // 12 banned names — past the search threshold (>8) and a page (>10) so the ban + // list's filter + paging demo too; kept distinct from the online roster so the + // mock reads like a real server (you don't ban who's currently on). + banned: [ + "Griefer_99", "tnt_troll", "hack_client_x", "spam_bot_01", "lava_caster", + "dupe_glitcher", "griefKing", "nukebot", "AFK_farmer", "chat_spammer", + "xray_cheater", "fly_hacker", + ], + // 12 online, matching the server's players:12 — past the search threshold (>8) + // and a page (>10) so the roster's filter + paging are both exercisable, with a + // few non-whitelisted names to try kick / ban on. + online: [ + "mock_player", "test_player", "Notch", "Steve", "Alex", "jeb_", + "Dinnerbone", "Griefer_88", "rndGuest_7", "xX_Raider_Xx", "creeper_fan", "Herobrine", + ], + }, + }, }; } @@ -552,10 +597,174 @@ function handleServerRoute(ctx: SessionContext): boolean { claimServer(ctx, serverInfo); return true; } + if (ctx.parts[4] === "access") { + return handleAccessMock(ctx, serverInfo); + } return false; } +function accessFor(state: MockState, name: string): AccessState { + let entry = state.access[name]; + if (!entry) { + entry = { whitelist: [], banned: [], online: [] }; + state.access[name] = entry; + } + return entry; +} + +function whitelistOutput(players: string[]): string { + if (players.length === 0) return "There are no whitelisted players"; + return `There are ${players.length} whitelisted player(s): ${players.join(", ")}`; +} + +function listOutput(online: string[], max: number): string { + const head = `There are ${online.length} of a max of ${max} players online:`; + return online.length === 0 ? head : `${head} ${online.join(", ")}`; +} + +// banlistOutput reproduces vanilla's multiline "banlist" reply: a header line then +// one " was banned by : " line per ban. The panel's parser +// (parseBanlistOutput) keys on the " was banned by " marker, so this exercises the +// real shape — header + reasons that carry their own colons and spaces — end to end. +function banlistOutput(banned: string[]): string { + if (banned.length === 0) return "There are no bans."; + const head = `There are ${banned.length} ban(s):`; + const lines = banned.map((p) => `${p} was banned by Server: Banned by an operator.`); + return [head, ...lines].join("\n"); +} + +// handleAccessMock mirrors issueAccessCommand's two gates — owner/admin AND the +// server being Running (RCON) — before dispatching the whitelist/ban routes. The GET +// whitelist read is behind the SAME Running gate as the writes, exactly as the real +// readiness check covers it (409 not_running on a cold server). +function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean { + if (!canManage(ctx.account, serverInfo)) { + sendError(ctx.res, 403, "forbidden", "server is not owned by this account"); + return true; + } + if (serverInfo.phase !== "Running") { + sendError( + ctx.res, + 409, + "not_running", + "server is not running; wake it before managing access", + ); + return true; + } + + const sub = ctx.parts[5]; + const access = accessFor(ctx.state, serverInfo.name); + + if (is("GET", ctx) && sub === "whitelist") { + sendJSON(ctx.res, 200, { + name: serverInfo.name, + players: [...access.whitelist], + output: whitelistOutput(access.whitelist), + }); + return true; + } + if (is("POST", ctx) && sub === "whitelist") { + void handleListMutation(ctx, serverInfo, access, "whitelist"); + return true; + } + if (is("GET", ctx) && sub === "players") { + const max = serverInfo.maxPlayers ?? 0; + sendJSON(ctx.res, 200, { + name: serverInfo.name, + online: access.online.length, + max, + players: [...access.online], + output: listOutput(access.online, max), + }); + return true; + } + if (is("POST", ctx) && sub === "kick") { + void handleKickMock(ctx, serverInfo, access); + return true; + } + if (is("GET", ctx) && sub === "ban") { + sendJSON(ctx.res, 200, { + name: serverInfo.name, + players: [...access.banned], + output: banlistOutput(access.banned), + }); + return true; + } + if (is("POST", ctx) && sub === "ban") { + void handleListMutation(ctx, serverInfo, access, "ban"); + return true; + } + + return false; +} + +// handleKickMock backs POST .../access/kick: charset-validate the player, drop them +// from the online roster (so a reload reflects it), and echo {name, player, output}. +async function handleKickMock( + ctx: SessionContext, + serverInfo: MockServer, + access: AccessState, +): Promise { + const body = await readJSON<{ player?: string }>(ctx.req); + const player = body.player?.trim() ?? ""; + if (!PLAYER_NAME.test(player)) { + sendError(ctx.res, 400, "bad_request", "invalid player name"); + return; + } + const i = access.online.indexOf(player); + if (i >= 0) access.online.splice(i, 1); + sendJSON(ctx.res, 200, { + name: serverInfo.name, + player, + output: `[mock] kick ${player}`, + }); +} + +// handleListMutation backs both POST .../access/whitelist (add|remove) and +// POST .../access/ban (ban|pardon): the same structured {action, player} shape with +// a charset-validated player, echoing back {name, action, player, output}. +async function handleListMutation( + ctx: SessionContext, + serverInfo: MockServer, + access: AccessState, + kind: "whitelist" | "ban", +): Promise { + const body = await readJSON<{ action?: string; player?: string }>(ctx.req); + const player = body.player?.trim() ?? ""; + if (!PLAYER_NAME.test(player)) { + sendError(ctx.res, 400, "bad_request", "invalid player name"); + return; + } + + const list = kind === "whitelist" ? access.whitelist : access.banned; + const addAction = kind === "whitelist" ? "add" : "ban"; + const removeAction = kind === "whitelist" ? "remove" : "pardon"; + + if (body.action === addAction) { + if (!list.includes(player)) list.push(player); + // A ban also removes the player from the live server, so drop them from the + // online roster too — the real "ban" kicks them as a side effect. + if (kind === "ban") { + const oi = access.online.indexOf(player); + if (oi >= 0) access.online.splice(oi, 1); + } + } else if (body.action === removeAction) { + const i = list.indexOf(player); + if (i >= 0) list.splice(i, 1); + } else { + sendError(ctx.res, 400, "bad_request", "unknown action"); + return; + } + + sendJSON(ctx.res, 200, { + name: serverInfo.name, + action: body.action, + player, + output: `[mock] ${body.action} ${player}`, + }); +} + async function handleCommandMock( ctx: SessionContext, serverInfo: MockServer, diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 6904a38..cf68007 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -9,6 +9,7 @@ import { ChangePassword } from "@/pages/ChangePassword"; import { Dashboard } from "@/pages/Dashboard"; import { MyServers } from "@/pages/MyServers"; import { ServerConsole } from "@/pages/ServerConsole"; +import { ServerPlayers } from "@/pages/ServerPlayers"; import { Account } from "@/pages/Account"; import { ServerAdmin } from "@/pages/admin/ServerAdmin"; import { ImageAdmin } from "@/pages/admin/ImageAdmin"; @@ -41,6 +42,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Admin-Side — admin-tier (server & content ops). diff --git a/panel/src/components/players/BansSection.tsx b/panel/src/components/players/BansSection.tsx new file mode 100644 index 0000000..0ae8e06 --- /dev/null +++ b/panel/src/components/players/BansSection.tsx @@ -0,0 +1,290 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Ban, Loader2, RotateCw, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { + CollapsibleSection, + FeedbackLine, + MC_NAME, + PagerFooter, + PlayerField, + SearchBox, + usePagedNames, + type Feedback, +} from "./shared"; + +/** BansSection is the ban roster: view who is blocked from the server, pardon any + * of them in one tap, and ban an arbitrary player by ID (the one player action + * whose target is NOT already on screen). Built to stay usable at a few hundred + * names — a live count, filter + paging (via usePagedNames), and a manual refresh. + * + * Two deliberately DIFFERENT confirmations, weighted by consequence: + * - Banning a typed-in name is the most surprising/destructive action here (the + * target isn't in front of you), so it arms a FULL-SENTENCE confirm that names + * the consequence — and Enter only ARMS it, never fires the ban. + * - Pardoning is recoverable (re-ban is one tap) but still security-relevant (it + * lets someone back in), so it gets a lighter one-step inline confirm per row. */ +export function BansSection({ name }: { name: string }) { + const { t } = useTranslation("servers"); + const { data, error, loading, reload } = useAsync(() => api.accessBanList(name), [name]); + const [value, setValue] = useState(""); + const [touched, setTouched] = useState(false); + const [armed, setArmed] = useState(false); // ban add-row: confirm shown, not yet fired + const [banning, setBanning] = useState(false); + const [pardoning, setPardoning] = useState(null); + const [confirming, setConfirming] = useState(null); // pardon row armed + const [fb, setFb] = useState(null); + + const player = value.trim(); + const valid = MC_NAME.test(player); + const invalid = touched && player.length > 0 && !valid; + + const players = useMemo(() => data?.players ?? [], [data]); + // Raw RCON reply is ground truth: the vanilla-only parse can come back empty on a + // plugin or localized "banlist" format while `output` still names the bans, so it + // stays available as a low-key disclosure rather than showing a false "no bans". + const raw = data?.output?.trim() ?? ""; + + const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } = + usePagedNames(players); + + // Enter and the Ban button only ARM the confirm — the ban never fires without the + // deliberate second click on the full-sentence confirmation below. + const arm = useCallback(() => { + if (!valid) { + setTouched(true); + return; + } + setFb(null); + setArmed(true); + }, [valid]); + + const ban = useCallback(async () => { + if (!valid || banning) return; + setFb(null); + setBanning(true); + try { + await api.accessBan(name, "ban", player); + setFb({ kind: "ok", msg: t("access_banned", { player }) }); + setValue(""); + setTouched(false); + setArmed(false); + reload(); + } catch (e) { + setFb({ kind: "err", msg: humanizeError(e) }); + } finally { + setBanning(false); + } + }, [name, player, valid, banning, reload, t]); + + const pardon = useCallback( + async (p: string) => { + setFb(null); + setPardoning(p); + try { + await api.accessBan(name, "pardon", p); + setFb({ kind: "ok", msg: t("access_pardoned", { player: p }) }); + reload(); + } catch (e) { + setFb({ kind: "err", msg: humanizeError(e) }); + } finally { + setPardoning(null); + setConfirming(null); + } + }, + [name, reload, t], + ); + + return ( + } + title={t("access_ban_title")} + count={!loading && !error ? players.length : undefined} + actions={ + + } + > +
+

{t("access_ban_desc")}

+ + {/* Ban-by-name — the one action whose target isn't already on screen, so it + is the most guarded: the button arms a full-sentence confirm rather than + firing, and Enter arms it too (PlayerField.onEnter={arm}). */} +
+
+
+ { + setValue(v); + setArmed(false); // editing the name re-disarms; confirm what you see + }} + onEnter={arm} + invalid={invalid} + disabled={banning} + /> +
+ +
+ {invalid &&

{t("access_player_invalid")}

} + + {armed && valid && ( +
+

+ {t("access_ban_confirm", { player })} +

+ + +
+ )} +
+ + {loading ? ( +
+ {t("log_connecting")} +
+ ) : error ? ( +

{t("access_ban_load_error")}

+ ) : players.length === 0 ? ( +
+

{t("access_ban_empty")}

+

{t("access_ban_empty_hint")}

+
+ ) : ( +
+ {showSearch && } + +
    + {shown.length === 0 ? ( +
  • + {t("access_search_no_match", { query: query.trim() })} +
  • + ) : ( + pageItems.map((p) => ( +
  • + + + {p} + + {/* Pardon lets a player back in, so it asks once: one tap arms the + row (取消 / 解封), a second confirms. Lighter than the ban-by-name + confirm because a mistaken pardon is re-bannable in one tap. */} + {confirming === p ? ( +
    + + {t("access_pardon_q")} + + + +
    + ) : ( + + )} +
  • + )) + )} +
+ + {needFooter && ( + + )} +
+ )} + + {/* Verbatim server reply — the escape hatch when the vanilla-only parse can't + tokenize a plugin or localized "banlist". Collapsed by default. */} + {!loading && !error && raw && ( +
+ + {t("access_ban_raw")} + +
+              {raw}
+            
+
+ )} + + +
+
+ ); +} diff --git a/panel/src/components/players/OnlineSection.tsx b/panel/src/components/players/OnlineSection.tsx new file mode 100644 index 0000000..7421ed1 --- /dev/null +++ b/panel/src/components/players/OnlineSection.tsx @@ -0,0 +1,238 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Ban, Loader2, LogOut, RotateCw, Users } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { + CollapsibleSection, + FeedbackLine, + PagerFooter, + SearchBox, + usePagedNames, + type Feedback, +} from "./shared"; + +type RowAction = "kick" | "ban"; + +/** OnlineSection is the live roster: who is on the server right now, each with a + * one-click kick or ban (both two-step confirmed, since both are disruptive). It + * is the ONLY place the panel learns WHO is online — status carries the count + * alone — so it reads the RCON "list" reply on demand. Refresh is MANUAL (a button + * + a last-updated stamp), never a timer: auto-polling would fire an RCON command + * per viewer forever, and the roster does not move fast enough to justify it. */ +export function OnlineSection({ name }: { name: string }) { + const { t } = useTranslation("servers"); + const { data, error, loading, reload } = useAsync(() => api.accessPlayers(name), [name]); + const [updatedAt, setUpdatedAt] = useState(null); + const [confirming, setConfirming] = useState<{ player: string; action: RowAction } | null>(null); + const [pending, setPending] = useState<{ player: string; action: RowAction } | null>(null); + const [fb, setFb] = useState(null); + + // Stamp the last successful read so the roster's freshness is always visible — + // the honest counterpart to not auto-refreshing. + useEffect(() => { + if (data) setUpdatedAt(new Date()); + }, [data]); + + const online = data?.online ?? 0; + const max = data?.max ?? 0; + const players = useMemo(() => data?.players ?? [], [data]); + const raw = data?.output?.trim() ?? ""; + // The tally says someone is on but no names parsed (a non-vanilla "list" format): + // report the count honestly and point at the raw reply rather than a false empty. + const namesUnavailable = online > 0 && players.length === 0; + + const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } = + usePagedNames(players); + + const run = useCallback( + async (playerName: string, action: RowAction) => { + setFb(null); + setPending({ player: playerName, action }); + try { + if (action === "kick") await api.accessKick(name, playerName); + else await api.accessBan(name, "ban", playerName); + setFb({ + kind: "ok", + msg: t(action === "kick" ? "access_kicked" : "access_banned", { player: playerName }), + }); + reload(); // the player just left — refresh so the roster reflects it + } catch (e) { + setFb({ kind: "err", msg: humanizeError(e) }); + } finally { + setPending(null); + setConfirming(null); + } + }, + [name, reload, t], + ); + + return ( + } + title={t("access_online_title")} + count={!loading && !error ? (max > 0 ? `${online} / ${max}` : online) : undefined} + actions={ + + } + > +
+ {/* Freshness stamp — the honest counterpart to manual refresh — sits with the + section's description now that the card header is just the collapsed index. */} +
+

{t("access_online_desc")}

+ {updatedAt && !loading && ( + + {t("access_updated_at", { time: updatedAt.toLocaleTimeString() })} + + )} +
+ + {loading && !data ? ( +
+ {t("log_connecting")} +
+ ) : error ? ( +

{t("access_online_load_error")}

+ ) : players.length === 0 ? ( +
+ {namesUnavailable ? ( + <> +

+ {t("access_online_names_unavailable", { count: online })} +

+

+ {t("access_online_names_unavailable_hint")} +

+ + ) : ( +

{t("access_online_empty")}

+ )} +
+ ) : ( +
+ {showSearch && } + +
    + {shown.length === 0 ? ( +
  • + {t("access_search_no_match", { query: query.trim() })} +
  • + ) : ( + pageItems.map((p) => { + // Narrow here so confirming.action is non-null inside the branch. + const c = confirming && confirming.player === p ? confirming : null; + const isPending = pending?.player === p; + return ( +
  • + + + {p} + + {/* Both kick and ban are disruptive, so each arms a one-step + inline confirm before it fires (no native confirm()). */} + {c ? ( +
    + + {c.action === "kick" ? t("access_kick_q") : t("access_ban_q")} + + + +
    + ) : ( +
    + + +
    + )} +
  • + ); + }) + )} +
+ + {needFooter && ( + + )} +
+ )} + + {/* Raw RCON reply — the ground truth for names when the parse can't tokenize + a non-vanilla "list" format. Collapsed by default. */} + {!loading && !error && raw && ( +
+ + {t("access_online_raw")} + +
+              {raw}
+            
+
+ )} + + +
+
+ ); +} diff --git a/panel/src/components/players/WhitelistSection.tsx b/panel/src/components/players/WhitelistSection.tsx new file mode 100644 index 0000000..ecb05d2 --- /dev/null +++ b/panel/src/components/players/WhitelistSection.tsx @@ -0,0 +1,241 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ListChecks, Loader2, Plus, RotateCw, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { + CollapsibleSection, + FeedbackLine, + MC_NAME, + PagerFooter, + PlayerField, + SearchBox, + usePagedNames, + type Feedback, +} from "./shared"; + +/** WhitelistSection manages the server's join whitelist: add a name, remove any + * entry, and read the current list. Built to stay usable at a few hundred names — + * a live count, a filter and paging (via usePagedNames) once the list is long + * enough to need them, and a two-step remove so a name never vanishes on one tap. */ +export function WhitelistSection({ name }: { name: string }) { + const { t } = useTranslation("servers"); + const { data, error, loading, reload } = useAsync( + () => api.accessWhitelistList(name), + [name], + ); + const [value, setValue] = useState(""); + const [touched, setTouched] = useState(false); + const [adding, setAdding] = useState(false); + const [removing, setRemoving] = useState(null); + const [confirming, setConfirming] = useState(null); + const [fb, setFb] = useState(null); + + const player = value.trim(); + const valid = MC_NAME.test(player); + const invalid = touched && player.length > 0 && !valid; + + const players = useMemo(() => data?.players ?? [], [data]); + // The server always echoes the raw RCON text. Parsed `players` drives the list / + // empty state; `raw` is kept as an always-available, low-key disclosure — it is + // ground truth when the vanilla-only parse can't tokenize a plugin or localized + // whitelist (the names are in `output` even when `players` came back empty). + const raw = data?.output?.trim() ?? ""; + + const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } = + usePagedNames(players); + + const add = useCallback(async () => { + if (!valid || adding) { + setTouched(true); + return; + } + setFb(null); + setAdding(true); + try { + await api.accessWhitelist(name, "add", player); + setFb({ kind: "ok", msg: t("access_whitelist_added", { player }) }); + setValue(""); + setTouched(false); + reload(); + } catch (e) { + setFb({ kind: "err", msg: humanizeError(e) }); + } finally { + setAdding(false); + } + }, [name, player, valid, adding, reload, t]); + + const remove = useCallback( + async (p: string) => { + setFb(null); + setRemoving(p); + try { + await api.accessWhitelist(name, "remove", p); + setFb({ kind: "ok", msg: t("access_whitelist_removed", { player: p }) }); + reload(); + } catch (e) { + setFb({ kind: "err", msg: humanizeError(e) }); + } finally { + setRemoving(null); + setConfirming(null); + } + }, + [name, reload, t], + ); + + return ( + } + title={t("access_whitelist_title")} + count={!loading && !error ? players.length : undefined} + actions={ + + } + > +
+

{t("access_whitelist_desc")}

+ + {/* Add row — the primary action, kept at the top so it is always in reach. */} +
+
+
+ +
+ +
+ {invalid &&

{t("access_player_invalid")}

} +
+ + {loading ? ( +
+ {t("log_connecting")} +
+ ) : error ? ( +

{t("access_whitelist_load_error")}

+ ) : players.length === 0 ? ( +
+

{t("access_whitelist_empty")}

+

+ {t("access_whitelist_empty_hint")} +

+
+ ) : ( +
+ {showSearch && } + +
    + {shown.length === 0 ? ( +
  • + {t("access_search_no_match", { query: query.trim() })} +
  • + ) : ( + pageItems.map((p) => ( +
  • + {p} + {/* Removal asks once before it fires: one click arms the row (X → + 取消 / 移除), a second confirms. Recoverable, but a name gone on + a single stray tap is exactly the surprise to avoid. */} + {confirming === p ? ( +
    + + {t("access_whitelist_remove_q")} + + + +
    + ) : ( + + )} +
  • + )) + )} +
+ + {needFooter && ( + + )} +
+ )} + + {/* The server's verbatim reply, kept as an opt-in disclosure — the escape + hatch when the vanilla-only parse can't tokenize a plugin or localized + whitelist. Collapsed by default so it never clutters the common case. */} + {!loading && !error && raw && ( +
+ + {t("access_whitelist_raw")} + +
+              {raw}
+            
+
+ )} + + +
+
+ ); +} diff --git a/panel/src/components/players/shared.tsx b/panel/src/components/players/shared.tsx new file mode 100644 index 0000000..ee2c618 --- /dev/null +++ b/panel/src/components/players/shared.tsx @@ -0,0 +1,299 @@ +import { + useId, + useMemo, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronLeft, ChevronRight, Search } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +// MC_NAME mirrors the backend's mcNameRe (handlers_access.go): a Minecraft name is +// 1–16 chars of [A-Za-z0-9_]. Validating client-side gives instant feedback and +// matches exactly what the server accepts, so a well-formed name never round-trips +// just to learn the rule. The server re-validates regardless — this is UX, not +// trust (the structured field is the whole reason there is no injection surface). +export const MC_NAME = /^[A-Za-z0-9_]{1,16}$/; + +export type Feedback = { kind: "ok" | "err"; msg: string } | null; + +/** FeedbackLine is the shared inline result line for a player action: emerald on + * success, destructive on failure. There is no toast library — every section + * reports here, in place, right under the control that fired. */ +export function FeedbackLine({ fb }: { fb: Feedback }) { + if (!fb) return null; + return ( +

+ {fb.msg} +

+ ); +} + +/** PlayerField is the shared player-name input: a controlled text box that enforces + * the access charset live (showing the rule only once the user has typed something + * wrong) and fires onEnter so the keyboard-only path works in every section. */ +export function PlayerField({ + value, + onChange, + onEnter, + invalid, + disabled, +}: { + value: string; + onChange: (v: string) => void; + onEnter: () => void; + invalid: boolean; + disabled?: boolean; +}) { + const { t } = useTranslation("servers"); + return ( + onChange(e.target.value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + onEnter(); + } + }} + placeholder={t("access_player_placeholder")} + autoComplete="off" + autoCapitalize="none" + spellCheck={false} + maxLength={16} + disabled={disabled} + aria-invalid={invalid} + className={invalid ? "border-destructive focus-visible:ring-destructive" : undefined} + /> + ); +} + +/** CollapsibleSection is the shared shell for every player-management block. The + * page stacks several rosters (online, whitelist, bans); at a few hundred names + * each, showing them all expanded buries the one an admin actually wants. So each + * block collapses to a single index row — chevron, title, live count, and its + * refresh — and expands on click. The count and refresh stay visible while + * collapsed so the header doubles as an at-a-glance, refreshable index; `actions` + * therefore renders in both states, and only the body (`children`) is hidden. */ +export function CollapsibleSection({ + icon, + title, + count, + actions, + defaultOpen = false, + children, +}: { + icon: ReactNode; + title: string; + /** Shown as a muted badge beside the title; omit while loading so no stale/empty + * badge flashes. This is the number the collapsed index is worth reading. */ + count?: ReactNode; + /** Header controls kept visible in both states (e.g. refresh) — a sibling of the + * toggle, so clicking them never folds the section. */ + actions?: ReactNode; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + const contentId = useId(); + return ( + +
+ + {actions &&
{actions}
} +
+ {/* Expand / collapse animates the body's real height. The trick is the + grid-rows 0fr↔1fr transition: it is the one pure-CSS way to ease to an + UNKNOWN auto height (no JS measuring, no guessed max-height that would make + the easing feel wrong). Three layers, each with one job: + 1. the grid — animates the track height 0fr↔1fr; + 2. overflow-hidden + min-h-0 — clips the body while it rolls up (min-h-0 + defeats a grid item's automatic minimum size so it truly reaches 0); + `visibility` rides the SAME duration so, by the CSS visibility- + transition rule, the body stays visible until the roll-up finishes and + only THEN leaves the a11y tree — collapsed content is neither tabbable + nor read by a screen reader, yet still animates; + 3. the padded body — fades opacity in step so it doesn't pop. + motion-reduce collapses all of it to an instant toggle. */} +
+
+
+ {children} +
+
+
+
+ ); +} + +// Defaults shared by every roster list: page over 10 names at a time, and only +// show the filter once the list is long enough that the eye can't just scan it. +const PAGE_SIZE = 10; +const SEARCH_THRESHOLD = 8; + +/** usePagedNames is the shared list engine for every player roster (whitelist, + * online, bans): a client-side filter plus paging over the filtered result. A + * whitelist or a full server can run to hundreds of names, and paging a screenful + * at a time — after search narrows — beats a cramped scroll box. clampedPage keeps + * a stale-high page valid after a removal shrinks the list, so the view never + * lands on an empty page. Changing the query resets to the first page. */ +export function usePagedNames(names: string[]) { + const [query, setQuery] = useState(""); + const [page, setPage] = useState(0); + + const q = query.trim().toLowerCase(); + const shown = useMemo( + () => (q ? names.filter((n) => n.toLowerCase().includes(q)) : names), + [names, q], + ); + const showSearch = names.length > SEARCH_THRESHOLD; + const pageCount = Math.max(1, Math.ceil(shown.length / PAGE_SIZE)); + const clampedPage = Math.min(page, pageCount - 1); + const pageItems = shown.slice(clampedPage * PAGE_SIZE, clampedPage * PAGE_SIZE + PAGE_SIZE); + const needFooter = shown.length > PAGE_SIZE || (q !== "" && shown.length > 0); + + const onQuery = (v: string) => { + setQuery(v); + setPage(0); + }; + + return { + query, + onQuery, + q, + shown, + showSearch, + pageItems, + pageCount, + clampedPage, + needFooter, + setPage, + }; +} + +/** SearchBox is the shared roster filter input — a search-icon-prefixed field. */ +export function SearchBox({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + const { t } = useTranslation("servers"); + return ( +
+ + onChange(e.target.value)} + placeholder={t("access_search_placeholder")} + autoComplete="off" + spellCheck={false} + className="h-8 pl-8 text-sm" + /> +
+ ); +} + +/** PagerFooter is the shared roster footer: a live count on the left (total, or + * filtered-of-total while searching) and prev / page-of / next controls on the + * right, shown only when there is more than one page. */ +export function PagerFooter({ + q, + shownCount, + total, + pageCount, + clampedPage, + onPage, +}: { + q: string; + shownCount: number; + total: number; + pageCount: number; + clampedPage: number; + onPage: (page: number) => void; +}) { + const { t } = useTranslation("servers"); + return ( +
+ + {q + ? t("access_search_count", { shown: shownCount, total }) + : t("access_total_count", { total })} + + {pageCount > 1 && ( +
+ + + {t("access_page_indicator", { page: clampedPage + 1, pages: pageCount })} + + +
+ )} +
+ ); +} diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 0d20c02..161a923 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -12,6 +12,8 @@ "subdomain_taken": "That subdomain is already in use.", "already_exists": "A server with that name already exists.", "cooldown": "Wake is cooling down — try again shortly.", + "not_running": "The server isn't running — wake it before managing access.", + "console_unavailable": "Can't reach the server console right now — try again shortly.", "session_expired": "Your session expired — please sign in again.", "forbidden": "You are not allowed to do that.", "generic": "Something went wrong." diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 03fd0c0..cf7232d 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -28,7 +28,6 @@ "console_offline_body": "The live console attaches automatically as soon as the server is running.", "my_servers_breadcrumb": "My servers", "console_card_title": "Console", - "console_card_desc": "Live, read-only output streamed from the running pod over SSE. Commands run through a separate path — the panel never holds an RCON password.", "command_placeholder": "Type a command… e.g. list", "log_connecting": "Connecting…", "log_live": "Live", @@ -39,6 +38,62 @@ "log_ended_empty": "Stream ended — reconnect to resume following the log.", "log_waiting": "Waiting for output…", "log_jump_latest": "Jump to latest", + "players_title": "Player management", + "players_link_title": "Player management", + "players_link_desc": "Manage the whitelist, online players, and bans.", + "players_back_to_console": "Back to console", + "players_not_yours_title": "Not your server", + "players_not_yours_body": "Only the owner or an admin can manage this server's players.", + "players_not_running_title": "Server is asleep", + "players_not_running_body": "Player management runs over a live connection to the server. Wake it to manage the whitelist, online players, and bans.", + "access_refresh": "Refresh", + "access_search_placeholder": "Search players…", + "access_search_no_match": "No players match \"{{query}}\".", + "access_search_count": "Showing {{shown}} of {{total}}", + "access_total_count": "{{total}} total", + "access_page_prev": "Prev", + "access_page_next": "Next", + "access_page_indicator": "Page {{page}} / {{pages}}", + "access_player_placeholder": "Player name, e.g. Notch", + "access_player_invalid": "Player names are 1–16 letters, digits or underscores.", + "access_whitelist_title": "Whitelist", + "access_whitelist_desc": "When the whitelist is on, only listed players can join.", + "access_whitelist_add": "Add", + "access_whitelist_empty": "No players on the whitelist yet.", + "access_whitelist_empty_hint": "Add a player above to let them join.", + "access_whitelist_remove": "Remove {{player}} from the whitelist", + "access_whitelist_remove_q": "Remove?", + "access_remove": "Remove", + "access_whitelist_load_error": "Couldn't load the whitelist.", + "access_whitelist_added": "Added {{player}} to the whitelist.", + "access_whitelist_removed": "Removed {{player}} from the whitelist.", + "access_whitelist_raw": "View raw server reply", + "access_online_title": "Online players", + "access_online_desc": "Players on the server right now.", + "access_online_load_error": "Couldn't load online players.", + "access_online_empty": "No one is online right now.", + "access_online_names_unavailable": "{{count}} online, but the names couldn't be read.", + "access_online_names_unavailable_hint": "See the raw server reply below for names.", + "access_online_raw": "View raw server reply", + "access_updated_at": "Updated {{time}}", + "access_kick_btn": "Kick", + "access_kick_q": "Kick?", + "access_ban_q": "Ban & block rejoin?", + "access_kicked": "Kicked {{player}}.", + "access_ban_title": "Bans", + "access_ban_desc": "Banning kicks a player and blocks them from rejoining. Expand to view the current ban list and pardon in one tap, or enter a full player ID to ban directly.", + "access_ban_btn": "Ban", + "access_pardon_btn": "Pardon", + "access_pardon_q": "Pardon & allow rejoin?", + "access_ban_confirm": "Ban {{player}}? They'll be kicked and blocked from rejoining.", + "access_ban_confirm_yes": "Ban", + "access_ban_empty": "No banned players.", + "access_ban_empty_hint": "Banned players show up here, ready to pardon in one tap.", + "access_ban_load_error": "Couldn't load the ban list.", + "access_ban_raw": "View raw server reply", + "access_cancel": "Cancel", + "access_banned": "Banned {{player}}.", + "access_pardoned": "Pardoned {{player}}.", "create_server_btn": "New server", "create_server_title": "Create a server", "create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index d8bf1d1..d7667ed 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -12,6 +12,8 @@ "subdomain_taken": "该子域名已被占用。", "already_exists": "同名服务器已存在。", "cooldown": "启动冷却中——请稍后再试。", + "not_running": "服务器未在运行——请先唤醒它再管理访问权限。", + "console_unavailable": "暂时无法连接服务器控制台,请稍后重试。", "session_expired": "会话已过期——请重新登录。", "forbidden": "你无权执行此操作。", "generic": "出了点问题,请稍后重试。" diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index ad3c6fd..0826760 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -28,7 +28,6 @@ "console_offline_body": "服务器运行后,实时控制台将自动连接。", "my_servers_breadcrumb": "我的服务器", "console_card_title": "控制台", - "console_card_desc": "通过 SSE 从 Pod 实时流式输出的只读日志。命令执行走独立通道——面板不持有 RCON 密码。", "command_placeholder": "输入命令…如 list", "log_connecting": "连接中…", "log_live": "实时", @@ -39,6 +38,62 @@ "log_ended_empty": "连接已断开——重连后可恢复日志输出。", "log_waiting": "等待输出…", "log_jump_latest": "滚动到最新", + "players_title": "玩家管理", + "players_link_title": "玩家管理", + "players_link_desc": "管理白名单、在线玩家与封禁。", + "players_back_to_console": "返回控制台", + "players_not_yours_title": "这不是你的服务器", + "players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。", + "players_not_running_title": "服务器已休眠", + "players_not_running_body": "玩家管理需要与服务器保持实时连接。唤醒后即可管理白名单、在线玩家与封禁。", + "access_refresh": "刷新", + "access_search_placeholder": "搜索玩家…", + "access_search_no_match": "没有匹配「{{query}}」的玩家。", + "access_search_count": "显示 {{shown}} / {{total}}", + "access_total_count": "共 {{total}} 人", + "access_page_prev": "上一页", + "access_page_next": "下一页", + "access_page_indicator": "第 {{page}} / {{pages}} 页", + "access_player_placeholder": "玩家 ID,如 Notch", + "access_player_invalid": "玩家 ID 仅支持字母、数字和下划线,最多 16 位。", + "access_whitelist_title": "白名单", + "access_whitelist_desc": "白名单开启时,仅名单内玩家可进服。", + "access_whitelist_add": "添加", + "access_whitelist_empty": "白名单暂无玩家。", + "access_whitelist_empty_hint": "在上方添加玩家即可放行进服。", + "access_whitelist_remove": "将 {{player}} 移出白名单", + "access_whitelist_remove_q": "移除?", + "access_remove": "移除", + "access_whitelist_load_error": "无法加载白名单。", + "access_whitelist_added": "已将 {{player}} 加入白名单。", + "access_whitelist_removed": "已将 {{player}} 移出白名单。", + "access_whitelist_raw": "查看服务器原始返回", + "access_online_title": "在线玩家", + "access_online_desc": "当前在服务器上的玩家。", + "access_online_load_error": "无法加载在线玩家。", + "access_online_empty": "当前无人在线。", + "access_online_names_unavailable": "{{count}} 人在线,但无法解析名单。", + "access_online_names_unavailable_hint": "在下方查看服务器原始返回获取名单。", + "access_online_raw": "查看服务器原始返回", + "access_updated_at": "更新于 {{time}}", + "access_kick_btn": "踢出", + "access_kick_q": "踢出?", + "access_ban_q": "封禁并禁止再进?", + "access_kicked": "已踢出 {{player}}。", + "access_ban_title": "封禁", + "access_ban_desc": "封禁会将玩家踢出并禁止再次进入。展开可查看当前封禁名单并一键解封,也可输入完整玩家 ID 直接封禁。", + "access_ban_btn": "封禁", + "access_pardon_btn": "解封", + "access_pardon_q": "解封并允许再进?", + "access_ban_confirm": "确认封禁 {{player}}?此玩家将被踢出并无法再进入。", + "access_ban_confirm_yes": "确认封禁", + "access_ban_empty": "暂无封禁玩家。", + "access_ban_empty_hint": "被封禁的玩家会显示在这里,可随时一键解封。", + "access_ban_load_error": "无法加载封禁名单。", + "access_ban_raw": "查看服务器原始返回", + "access_cancel": "取消", + "access_banned": "已封禁 {{player}}。", + "access_pardoned": "已解封 {{player}}。", "create_server_btn": "新建服务器", "create_server_title": "创建服务器", "create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。", diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index b5413a3..1e46b58 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -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).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).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).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).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).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).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); + }); +}); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 93c7528..0042f15 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -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("GET", `/servers/${name}/access/whitelist`), + + accessWhitelist: (name: string, action: "add" | "remove", player: string) => + request("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("GET", `/servers/${name}/access/ban`), + + accessBan: (name: string, action: "ban" | "pardon", player: string) => + request("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("GET", `/servers/${name}/access/players`), + + accessKick: (name: string, player: string) => + request("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"); diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index e5b0190..da71041 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -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 " was banned by : ", + * 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. diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index 3128c58..5b01547 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react"; import { Link, useParams } from "react-router-dom"; -import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, Loader2, type LucideIcon } from "lucide-react"; +import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, Loader2, Users, ChevronRight, type LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -237,9 +237,6 @@ export function ServerConsole() { {t("console_card_title")} -

- {t("console_card_desc")} -

{!streamable ? ( @@ -259,6 +256,21 @@ export function ServerConsole() { )} + + {/* Player management lives on its own subpage (whitelist / online / bans), + not crammed under the console. This is the doorway to it; the page + itself owns the ownership + readiness gating. */} + + +
+

{t("players_link_title")}

+

{t("players_link_desc")}

+
+ + ) : null} diff --git a/panel/src/pages/ServerPlayers.tsx b/panel/src/pages/ServerPlayers.tsx new file mode 100644 index 0000000..2bd26fd --- /dev/null +++ b/panel/src/pages/ServerPlayers.tsx @@ -0,0 +1,152 @@ +import { Link, useParams } from "react-router-dom"; +import { ArrowLeft, Moon, ShieldX, Users } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { PhaseBadge } from "@/components/PhaseBadge"; +import { Loading, ErrorState } from "@/components/States"; +import { OnlineSection } from "@/components/players/OnlineSection"; +import { WhitelistSection } from "@/components/players/WhitelistSection"; +import { BansSection } from "@/components/players/BansSection"; +import { api } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { useTier } from "@/lib/tier"; +import type { Phase } from "@/lib/types"; + +/** NotYours is the explicit "this server isn't yours to manage" state. Player + * management is owner-or-admin gated on the backend, but this page is a real route: + * a non-owner (or anyone who types the URL) reaches it, so it must say so plainly + * and offer a way back — never render blank. */ +function NotYours() { + const { t } = useTranslation("servers"); + return ( +
+ +
+

{t("players_not_yours_title")}

+

{t("players_not_yours_body")}

+
+ + {t("my_servers_breadcrumb")} + +
+ ); +} + +/** NotRunning is the page-level asleep state. Every section here needs a live RCON + * connection, so a stopped server has nothing to manage — the whole page collapses + * to one honest "wake it first" panel with the wake control, rather than three + * separately-empty sections. */ +function NotRunning({ onWake }: { onWake: () => void }) { + const { t } = useTranslation("servers"); + return ( +
+ +
+

{t("players_not_running_title")}

+

+ {t("players_not_running_body")} +

+
+ +
+ ); +} + +/** ServerPlayers is the per-server player-management subpage (/servers/:name/players): + * whitelist today, online roster and bans as they land. It owns its own gating — + * ownership (from /me/servers, since GET status never carries `owned`) and server + * readiness — because as a route it can be reached directly, not just from a link. */ +export function ServerPlayers() { + const { name = "" } = useParams(); + const { t } = useTranslation("servers"); + const { isAdmin, loading: tierLoading } = useTier(); + const { data, error, loading, reload } = useAsync(() => api.status(name), [name]); + const { + data: mine, + error: mineError, + reload: reloadMine, + } = useAsync( + () => (isAdmin ? Promise.resolve([]) : api.myServers()), + [isAdmin, name], + ); + + const back = ( + + {t("players_back_to_console")} + + ); + + if (loading && !data) { + return ( + <> + {back} + + + ); + } + if (error) { + return ( + <> + {back} + + + ); + } + if (!data) return back; + + // Ownership is still resolving (tier fetch, or /me/servers for a non-admin). We + // have the server's identity, so show its header with a spinner beneath it rather + // than flashing the whole management surface at someone who may not own it. If the + // /me/servers read itself failed, `mineError` breaks the pending state (below) so a + // real owner sees a retry instead of an eternal spinner — never fail closed to + // NotYours, which would wrongly tell an owner the server isn't theirs on a blip. + const ownershipPending = + tierLoading || (!isAdmin && mine === null && !mineError); + const owned = + isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true); + const phase: Phase = data.phase; + + const header = ( +
+
+ +
+

+ {data.displayName || data.name} +

+

{t("players_title")}

+
+
+ +
+ ); + + return ( + <> + {back} + {header} + {ownershipPending ? ( + + ) : mineError ? ( + + ) : !owned ? ( + + ) : phase !== "Running" ? ( + api.wake(name).then(reload)} /> + ) : ( +
+ {/* Ordered as a who-may-be-here gradient: who is on right now → who may + join → who may NOT. Each collapses to an index row (shared.tsx). */} + + + +
+ )} + + ); +}