feat(panel): player management
This commit is contained in:
19 files changed
+2282
-7
No files matched your search
@@ -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 <player>" 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]
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 "<name> was banned by <source>: <reason>" 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 <player>".
|
||||
// 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 "<name> 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 "<name> was banned by <source>: <reason>". 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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
-1
@@ -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<AccountID, MockAccount>;
|
||||
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<string, AccessState>;
|
||||
}
|
||||
|
||||
// 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 "<name> was banned by <source>: <reason>" 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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="servers" element={<MyServers />} />
|
||||
<Route path="servers/:name" element={<ServerConsole />} />
|
||||
<Route path="servers/:name/players" element={<ServerPlayers />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
|
||||
{/* Admin-Side — admin-tier (server & content ops).
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null); // pardon row armed
|
||||
const [fb, setFb] = useState<Feedback>(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 (
|
||||
<CollapsibleSection
|
||||
icon={<Ban className="h-4 w-4" />}
|
||||
title={t("access_ban_title")}
|
||||
count={!loading && !error ? players.length : undefined}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground"
|
||||
onClick={reload}
|
||||
disabled={loading}
|
||||
title={t("access_refresh")}
|
||||
aria-label={t("access_refresh")}
|
||||
>
|
||||
<RotateCw className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("access_ban_desc")}</p>
|
||||
|
||||
{/* 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}). */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<PlayerField
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
setArmed(false); // editing the name re-disarms; confirm what you see
|
||||
}}
|
||||
onEnter={arm}
|
||||
invalid={invalid}
|
||||
disabled={banning}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-9"
|
||||
onClick={arm}
|
||||
disabled={!valid || armed || banning}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
{t("access_ban_btn")}
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && <p className="text-xs text-destructive">{t("access_player_invalid")}</p>}
|
||||
|
||||
{armed && valid && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
|
||||
<p className="min-w-0 flex-1 text-xs text-foreground">
|
||||
{t("access_ban_confirm", { player })}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setArmed(false)}
|
||||
disabled={banning}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={ban}
|
||||
disabled={banning}
|
||||
>
|
||||
{banning ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t("access_ban_confirm_yes")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-destructive">{t("access_ban_load_error")}</p>
|
||||
) : players.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t("access_ban_empty")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">{t("access_ban_empty_hint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{showSearch && <SearchBox value={query} onChange={onQuery} />}
|
||||
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{shown.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("access_search_no_match", { query: query.trim() })}
|
||||
</li>
|
||||
) : (
|
||||
pageItems.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Ban className="h-3.5 w-3.5 shrink-0 text-destructive/70" />
|
||||
<span className="truncate font-mono text-sm">{p}</span>
|
||||
</span>
|
||||
{/* 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 ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="mr-1 hidden text-xs text-muted-foreground sm:inline">
|
||||
{t("access_pardon_q")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={pardoning === p}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => pardon(p)}
|
||||
disabled={pardoning !== null}
|
||||
>
|
||||
{pardoning === p ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t("access_pardon_btn")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2"
|
||||
onClick={() => setConfirming(p)}
|
||||
disabled={pardoning !== null}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("access_pardon_btn")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
q={q}
|
||||
shownCount={shown.length}
|
||||
total={players.length}
|
||||
pageCount={pageCount}
|
||||
clampedPage={clampedPage}
|
||||
onPage={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground transition-colors hover:text-foreground">
|
||||
{t("access_ban_raw")}
|
||||
</summary>
|
||||
<pre className="mt-1.5 whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-2.5 font-mono text-foreground">
|
||||
{raw}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<FeedbackLine fb={fb} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
@@ -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<Date | null>(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<Feedback>(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 (
|
||||
<CollapsibleSection
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
title={t("access_online_title")}
|
||||
count={!loading && !error ? (max > 0 ? `${online} / ${max}` : online) : undefined}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={reload}
|
||||
disabled={loading}
|
||||
title={t("access_refresh")}
|
||||
aria-label={t("access_refresh")}
|
||||
>
|
||||
<RotateCw className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Freshness stamp — the honest counterpart to manual refresh — sits with the
|
||||
section's description now that the card header is just the collapsed index. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">{t("access_online_desc")}</p>
|
||||
{updatedAt && !loading && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{t("access_updated_at", { time: updatedAt.toLocaleTimeString() })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-destructive">{t("access_online_load_error")}</p>
|
||||
) : players.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||
{namesUnavailable ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("access_online_names_unavailable", { count: online })}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">
|
||||
{t("access_online_names_unavailable_hint")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("access_online_empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{showSearch && <SearchBox value={query} onChange={onQuery} />}
|
||||
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{shown.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("access_search_no_match", { query: query.trim() })}
|
||||
</li>
|
||||
) : (
|
||||
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 (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span className="truncate font-mono text-sm">{p}</span>
|
||||
</span>
|
||||
{/* Both kick and ban are disruptive, so each arms a one-step
|
||||
inline confirm before it fires (no native confirm()). */}
|
||||
{c ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="mr-1 hidden text-xs text-muted-foreground sm:inline">
|
||||
{c.action === "kick" ? t("access_kick_q") : t("access_ban_q")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => run(p, c.action)}
|
||||
disabled={pending !== null}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : c.action === "kick" ? (
|
||||
t("access_kick_btn")
|
||||
) : (
|
||||
t("access_ban_btn")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setConfirming({ player: p, action: "kick" })}
|
||||
disabled={pending !== null}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("access_kick_btn")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setConfirming({ player: p, action: "ban" })}
|
||||
disabled={pending !== null}
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("access_ban_btn")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
q={q}
|
||||
shownCount={shown.length}
|
||||
total={players.length}
|
||||
pageCount={pageCount}
|
||||
clampedPage={clampedPage}
|
||||
onPage={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground transition-colors hover:text-foreground">
|
||||
{t("access_online_raw")}
|
||||
</summary>
|
||||
<pre className="mt-1.5 whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-2.5 font-mono text-foreground">
|
||||
{raw}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<FeedbackLine fb={fb} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [fb, setFb] = useState<Feedback>(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 (
|
||||
<CollapsibleSection
|
||||
icon={<ListChecks className="h-4 w-4" />}
|
||||
title={t("access_whitelist_title")}
|
||||
count={!loading && !error ? players.length : undefined}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground"
|
||||
onClick={reload}
|
||||
disabled={loading}
|
||||
title={t("access_refresh")}
|
||||
aria-label={t("access_refresh")}
|
||||
>
|
||||
<RotateCw className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("access_whitelist_desc")}</p>
|
||||
|
||||
{/* Add row — the primary action, kept at the top so it is always in reach. */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<PlayerField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onEnter={add}
|
||||
invalid={invalid}
|
||||
disabled={adding}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-9" onClick={add} disabled={!valid || adding}>
|
||||
{adding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("access_whitelist_add")}
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && <p className="text-xs text-destructive">{t("access_player_invalid")}</p>}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-destructive">{t("access_whitelist_load_error")}</p>
|
||||
) : players.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t("access_whitelist_empty")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">
|
||||
{t("access_whitelist_empty_hint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{showSearch && <SearchBox value={query} onChange={onQuery} />}
|
||||
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{shown.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("access_search_no_match", { query: query.trim() })}
|
||||
</li>
|
||||
) : (
|
||||
pageItems.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<span className="truncate font-mono text-sm">{p}</span>
|
||||
{/* 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 ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="mr-1 hidden text-xs text-muted-foreground sm:inline">
|
||||
{t("access_whitelist_remove_q")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={removing === p}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => remove(p)}
|
||||
disabled={removing !== null}
|
||||
>
|
||||
{removing === p ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t("access_remove")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(p)}
|
||||
disabled={removing !== null}
|
||||
aria-label={t("access_whitelist_remove", { player: p })}
|
||||
title={t("access_whitelist_remove", { player: p })}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
q={q}
|
||||
shownCount={shown.length}
|
||||
total={players.length}
|
||||
pageCount={pageCount}
|
||||
clampedPage={clampedPage}
|
||||
onPage={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground transition-colors hover:text-foreground">
|
||||
{t("access_whitelist_raw")}
|
||||
</summary>
|
||||
<pre className="mt-1.5 whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-2.5 font-mono text-foreground">
|
||||
{raw}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<FeedbackLine fb={fb} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<p
|
||||
role="status"
|
||||
className={fb.kind === "ok" ? "text-xs text-emerald-500" : "text-xs text-destructive"}
|
||||
>
|
||||
{fb.msg}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-out group-hover:text-foreground motion-reduce:transition-none",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{icon}
|
||||
<span className="truncate text-base font-semibold tracking-tight">{title}</span>
|
||||
{count != null && (
|
||||
<Badge variant="muted" className="font-normal tabular-nums">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
|
||||
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 overflow-hidden transition-[visibility] duration-200 motion-reduce:transition-none",
|
||||
open ? "visible" : "invisible",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
id={contentId}
|
||||
className={cn(
|
||||
"p-5 pt-0 transition-opacity duration-200 ease-out motion-reduce:transition-none",
|
||||
open ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t("access_search_placeholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{q
|
||||
? t("access_search_count", { shown: shownCount, total })
|
||||
: t("access_total_count", { total })}
|
||||
</span>
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => onPage(clampedPage - 1)}
|
||||
disabled={clampedPage === 0}
|
||||
aria-label={t("access_page_prev")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("access_page_prev")}</span>
|
||||
</Button>
|
||||
<span className="min-w-[4.5rem] text-center tabular-nums">
|
||||
{t("access_page_indicator", { page: clampedPage + 1, pages: pageCount })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => onPage(clampedPage + 1)}
|
||||
disabled={clampedPage >= pageCount - 1}
|
||||
aria-label={t("access_page_next")}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("access_page_next")}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"subdomain_taken": "该子域名已被占用。",
|
||||
"already_exists": "同名服务器已存在。",
|
||||
"cooldown": "启动冷却中——请稍后再试。",
|
||||
"not_running": "服务器未在运行——请先唤醒它再管理访问权限。",
|
||||
"console_unavailable": "暂时无法连接服务器控制台,请稍后重试。",
|
||||
"session_expired": "会话已过期——请重新登录。",
|
||||
"forbidden": "你无权执行此操作。",
|
||||
"generic": "出了点问题,请稍后重试。"
|
||||
|
||||
@@ -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": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Terminal className="h-4 w-4" /> {t("console_card_title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("console_card_desc")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!streamable ? (
|
||||
@@ -259,6 +256,21 @@ export function ServerConsole() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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. */}
|
||||
<Link
|
||||
to={`/servers/${name}/players`}
|
||||
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
|
||||
>
|
||||
<Users className="h-5 w-5 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{t("players_link_title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("players_link_desc")}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
|
||||
<ShieldX className="h-8 w-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium">{t("players_not_yours_title")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("players_not_yours_body")}</p>
|
||||
</div>
|
||||
<Link to="/servers" className="text-sm font-medium text-primary hover:underline">
|
||||
{t("my_servers_breadcrumb")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center">
|
||||
<Moon className="h-8 w-8 text-muted-foreground/70" />
|
||||
<div>
|
||||
<p className="font-medium">{t("players_not_running_title")}</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
{t("players_not_running_body")}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={onWake}>
|
||||
{t("wake")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 = (
|
||||
<Link
|
||||
to={`/servers/${name}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> {t("players_back_to_console")}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<Loading />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<ErrorState error={error} onRetry={reload} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 = (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{data.displayName || data.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("players_title")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PhaseBadge phase={phase} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
{header}
|
||||
{ownershipPending ? (
|
||||
<Loading />
|
||||
) : mineError ? (
|
||||
<ErrorState error={mineError} onRetry={reloadMine} />
|
||||
) : !owned ? (
|
||||
<NotYours />
|
||||
) : phase !== "Running" ? (
|
||||
<NotRunning onWake={() => api.wake(name).then(reload)} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 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). */}
|
||||
<OnlineSection name={name} />
|
||||
<WhitelistSection name={name} />
|
||||
<BansSection name={name} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user