From 6adb7b7dce1591b930db8156f31d4d220a631f1d Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Thu, 15 Oct 2026 15:12:21 +0000
Subject: [PATCH 1/4] Store the server's command list for the Commands tab
TFMCWeb puts the list to /luckperms/plugin/commands. Staff with the
LuckPerms view right read it from /admin/luckperms/commands, with groups
ordered as player ranks, staff ranks (each track's last group) and track
steps, and each command's access in that order.
Co-Authored-By: Claude Opus 5.5
---
backend/src/api/luckperms_routes.py | 17 ++-
backend/src/luckperms/commands.py | 168 +++++++++++++++++++++++++
backend/src/luckperms/test_commands.py | 121 ++++++++++++++++++
backend/src/skins/schema.sql | 11 ++
4 files changed, 316 insertions(+), 1 deletion(-)
create mode 100644 backend/src/luckperms/commands.py
create mode 100644 backend/src/luckperms/test_commands.py
diff --git a/backend/src/api/luckperms_routes.py b/backend/src/api/luckperms_routes.py
index 309be8b0..34dfcfc4 100644
--- a/backend/src/api/luckperms_routes.py
+++ b/backend/src/api/luckperms_routes.py
@@ -16,10 +16,11 @@
from src.api.patreon_routes import require_writer
from src.api.request_body import read_json_body
from src.auth import admin
-from src.luckperms import changes, mirror, views
+from src.luckperms import changes, commands, mirror, views
SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024
RESULTS_MAX_BYTES = 4 * 1024 * 1024
+COMMANDS_MAX_BYTES = 8 * 1024 * 1024
plugin_router = APIRouter(prefix="/luckperms/plugin", tags=["luckperms"], dependencies=[Depends(require_writer)])
staff_router = APIRouter(prefix="/admin/luckperms", tags=["admin"])
@@ -34,6 +35,15 @@ async def put_snapshot(request: Request):
raise HTTPException(400, detail=exc.code) from None
+@plugin_router.put("/commands")
+async def put_commands(request: Request):
+ payload = await read_json_body(request, COMMANDS_MAX_BYTES)
+ try:
+ return await run_in_threadpool(commands.store, payload)
+ except commands.CommandsError as exc:
+ raise HTTPException(400, detail=exc.code) from None
+
+
class HashBody(BaseModel):
hash: str = Field(..., max_length=128)
revision: int = Field(..., gt=0)
@@ -82,6 +92,11 @@ def get_overview(request: Request, response: Response):
return _read(request, response, views.overview)
+@staff_router.get("/commands")
+def get_commands(request: Request, response: Response):
+ return _read(request, response, commands.view)
+
+
@staff_router.get("/groups/{name}")
def get_group(name: str, request: Request, response: Response):
return _read(request, response, lambda viewer: views.group_detail(viewer, name))
diff --git a/backend/src/luckperms/commands.py b/backend/src/luckperms/commands.py
new file mode 100644
index 00000000..1185a4a5
--- /dev/null
+++ b/backend/src/luckperms/commands.py
@@ -0,0 +1,168 @@
+"""The server's commands and which LuckPerms groups may use them, for the Commands tab.
+
+TFMCWeb publishes the whole list; it replaces the stored one. Access is
+decided on the server from each command's own permission, so plugins may
+still refuse a command when it runs.
+"""
+from __future__ import annotations
+
+import json
+import time
+
+from src.skins.db import connect
+
+from . import mirror, nodes
+
+GROUPS_MAX = 200
+PLUGINS_MAX = 500
+COMMANDS_MAX = 10_000
+ALIASES_MAX = 64
+NAME_MAX = 64
+TEXT_MAX = 300
+PERMISSION_MAX = 512
+# TFMCWeb resends at least every ten minutes.
+FRESH_SECONDS = 30 * 60
+KIND_ORDER = {"rank": 0, "staff": 1, "step": 2}
+
+
+class CommandsError(Exception):
+ def __init__(self, code: str):
+ super().__init__(code)
+ self.code = code
+
+
+def _text(raw, limit: int, *, required: bool = False) -> str | None:
+ if raw is None and not required:
+ return None
+ if not isinstance(raw, str) or not raw or len(raw) > limit:
+ raise CommandsError("bad_commands")
+ return raw
+
+
+def _command(raw, plugins: set[str], groups: int) -> dict:
+ if not isinstance(raw, dict):
+ raise CommandsError("bad_commands")
+ aliases = raw.get("aliases")
+ if not isinstance(aliases, list) or len(aliases) > ALIASES_MAX:
+ raise CommandsError("bad_commands")
+ plugin = _text(raw.get("plugin"), NAME_MAX, required=True)
+ if plugin not in plugins or not isinstance(raw.get("shadowed"), bool):
+ raise CommandsError("bad_commands")
+ permission = _text(raw.get("permission"), PERMISSION_MAX)
+ access = raw.get("access")
+ if permission is None:
+ if access is not None:
+ raise CommandsError("bad_commands")
+ elif not isinstance(access, str) or len(access) != groups or access.strip("yn"):
+ raise CommandsError("bad_commands")
+ return {
+ "plugin": plugin,
+ "name": _text(raw.get("name"), NAME_MAX, required=True),
+ "aliases": [_text(a, NAME_MAX, required=True) for a in aliases],
+ "description": _text(raw.get("description"), TEXT_MAX),
+ "usage": _text(raw.get("usage"), TEXT_MAX),
+ "permission": permission,
+ "shadowed": raw["shadowed"],
+ "access": access,
+ }
+
+
+def store(payload) -> dict:
+ if not isinstance(payload, dict):
+ raise CommandsError("bad_commands")
+ groups, plugins, commands = payload.get("groups"), payload.get("plugins"), payload.get("commands")
+ if (not isinstance(groups, list) or not isinstance(plugins, list) or not isinstance(commands, list)
+ or len(groups) > GROUPS_MAX or len(plugins) > PLUGINS_MAX or len(commands) > COMMANDS_MAX):
+ raise CommandsError("bad_commands")
+ group_names = [_text(g, NAME_MAX * 2, required=True).lower() for g in groups]
+ plugin_rows = []
+ for raw in plugins:
+ if not isinstance(raw, dict):
+ raise CommandsError("bad_commands")
+ plugin_rows.append({"name": _text(raw.get("name"), NAME_MAX, required=True),
+ "version": _text(raw.get("version"), NAME_MAX)})
+ names = {p["name"] for p in plugin_rows}
+ command_rows = [_command(raw, names, len(group_names)) for raw in commands]
+ generated_at = payload.get("generated_at")
+ if not isinstance(generated_at, int) or isinstance(generated_at, bool) or generated_at <= 0:
+ raise CommandsError("bad_commands")
+ server = _text(payload.get("server"), 32, required=True)
+ snapshot_hash = _text(payload.get("hash"), 128)
+ body = json.dumps({"groups": group_names, "plugins": plugin_rows, "commands": command_rows},
+ separators=(",", ":"))
+ with connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO lp_commands (id, server, hash, generated_at, received_at, payload)
+ VALUES (1, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET server = excluded.server, hash = excluded.hash,
+ generated_at = excluded.generated_at, received_at = excluded.received_at,
+ payload = excluded.payload
+ """,
+ (server, snapshot_hash, generated_at, mirror._iso(), body),
+ )
+ conn.commit()
+ return {"ok": True, "commands": len(command_rows)}
+
+
+def _kinds(tracks: list[dict]) -> dict[str, tuple[str, str]]:
+ """A track's last group is a staff rank; the groups before it are its steps."""
+ out: dict[str, tuple[str, str]] = {}
+ for track in tracks:
+ if not track["groups"]:
+ continue
+ *steps, last = track["groups"]
+ out[last] = ("staff", track["name"])
+ for step in steps:
+ out.setdefault(step, ("step", track["name"]))
+ return out
+
+
+def view(viewer: dict) -> dict:
+ now = int(time.time())
+ with connect() as conn:
+ row = conn.execute(
+ "SELECT server, generated_at, received_at, payload FROM lp_commands WHERE id = 1").fetchone()
+ if row is None:
+ return {"status": None, "groups": [], "plugins": []}
+ groups = mirror.load_groups(conn)
+ counts = mirror.group_counts(conn)
+ tracks = mirror.load_tracks(conn)
+ data = json.loads(row["payload"])
+ kinds = _kinds(tracks)
+ listed = []
+ for index, name in enumerate(data["groups"]):
+ group = groups.get(name, {"name": name, "display_name": None, "weight": None, "nodes": []})
+ kind, track = kinds.get(name, ("rank", None))
+ listed.append((index, {
+ "name": name,
+ "display_name": group["display_name"],
+ "weight": group["weight"],
+ "members": counts.get(name, 0),
+ "kind": kind,
+ "track": track,
+ "parents": mirror.parents(group, now),
+ "nodes": [n for n in group["nodes"] if nodes.kind(n["key"]) == "permission"],
+ }))
+ listed.sort(key=lambda item: (KIND_ORDER[item[1]["kind"]], item[1]["weight"] or 0, item[1]["name"]))
+ order = [index for index, _ in listed]
+ plugins: dict[str, dict] = {p["name"]: {**p, "commands": []} for p in data["plugins"]}
+ for command in data["commands"]:
+ access = command["access"]
+ plugins[command["plugin"]]["commands"].append({
+ **{k: v for k, v in command.items() if k != "plugin"},
+ "access": None if access is None else "".join(access[i] for i in order),
+ })
+ for plugin in plugins.values():
+ plugin["commands"].sort(key=lambda c: (c["name"], c["shadowed"]))
+ received = mirror._age(row["received_at"], time.time())
+ return {
+ "status": {
+ "server": row["server"],
+ "generated_at": row["generated_at"],
+ "received_at": row["received_at"],
+ "fresh": received is not None and received <= FRESH_SECONDS,
+ },
+ "groups": [group for _, group in listed],
+ "plugins": sorted((p for p in plugins.values() if p["commands"]), key=lambda p: p["name"].lower()),
+ }
diff --git a/backend/src/luckperms/test_commands.py b/backend/src/luckperms/test_commands.py
new file mode 100644
index 00000000..81face44
--- /dev/null
+++ b/backend/src/luckperms/test_commands.py
@@ -0,0 +1,121 @@
+import pytest
+
+from src.luckperms import commands
+from src.luckperms.test_luckperms import PLUGIN, SECONDARY, app, bridge, client, env, staff # noqa: F401
+
+
+def command(name, plugin="Essentials", permission=None, access=None, **extra):
+ return {"plugin": plugin, "name": name, "aliases": extra.get("aliases", []),
+ "description": extra.get("description"), "usage": extra.get("usage"), "permission": permission,
+ "shadowed": extra.get("shadowed", False), "access": access}
+
+
+def catalogue(**overrides):
+ # Groups in the plugin's (alphabetical) order; access follows it.
+ payload = {
+ "server": "main",
+ "generated_at": 1792076432,
+ "hash": "c1",
+ "groups": ["commoner", "default", "helper", "helper_inactive", "helper_player", "noble", "staff"],
+ "plugins": [{"name": "Essentials", "version": "2.22.0"}, {"name": "LibsDisguises", "version": None},
+ {"name": "Unused", "version": "1"}],
+ "commands": [
+ command("me", permission="essentials.me", access="yyyyyyy", aliases=["eme"],
+ description="Describes an action."),
+ command("kick", permission="essentials.kick", access="nnynnny"),
+ command("disguise", plugin="LibsDisguises", permission="libsdisguises.seecmd.disguise",
+ access="yyyyyyy", usage="/disguise "),
+ command("me", plugin="LibsDisguises", shadowed=True),
+ ],
+ }
+ payload.update(overrides)
+ return payload
+
+
+def test_plugin_route_needs_the_primary_key(app):
+ plugin = client(app)
+ assert plugin.put("/luckperms/plugin/commands", json=catalogue()).status_code == 401
+ assert plugin.put("/luckperms/plugin/commands", headers=SECONDARY, json=catalogue()).status_code == 403
+
+
+@pytest.mark.parametrize("change", [
+ {"groups": "x"},
+ {"groups": [""]},
+ {"plugins": ["x"]},
+ {"commands": [command("me", plugin="Nobody")]},
+ {"commands": [command("me", permission="essentials.me", access="yy")]},
+ {"commands": [command("me", permission="essentials.me", access="yyyyyyx")]},
+ {"commands": [command("me", access="yyyyyyy")]},
+ {"commands": [{**command("me"), "shadowed": "no"}]},
+ {"commands": [{**command("me"), "aliases": "eme"}]},
+ {"commands": [command("x" * 65)]},
+ {"commands": ["me"]},
+ {"generated_at": True},
+ {"server": None},
+])
+def test_malformed_lists_are_refused(app, change):
+ response = client(app).put("/luckperms/plugin/commands", headers=PLUGIN, json=catalogue(**change))
+ assert response.status_code == 400 and response.json()["detail"] == "bad_commands"
+ assert client(app).put("/luckperms/plugin/commands", headers=PLUGIN, json=[1]).status_code == 400
+
+
+def test_staff_see_commands_with_groups_in_rank_order(app, bridge, staff):
+ mod = client(app, staff["mod"])
+ assert mod.get("/admin/luckperms/commands").json() == {"status": None, "groups": [], "plugins": []}
+ assert bridge.put("/luckperms/plugin/commands", headers=PLUGIN, json=catalogue()).json() == {
+ "ok": True, "commands": 4}
+
+ data = mod.get("/admin/luckperms/commands").json()
+
+ assert data["status"]["server"] == "main" and data["status"]["generated_at"] == 1792076432
+ assert data["status"]["fresh"] is True
+ assert [(g["name"], g["kind"], g["track"]) for g in data["groups"]] == [
+ ("default", "rank", None), ("commoner", "rank", None), ("noble", "rank", None),
+ ("helper", "staff", "helper"), ("staff", "staff", "staff"),
+ ("helper_player", "step", "helper"), ("helper_inactive", "step", "helper"),
+ ]
+ helper = data["groups"][3]
+ assert helper["parents"] == ["helper_inactive"] and helper["members"] == 0
+ assert [n["key"] for n in helper["nodes"]] == ["essentials.kick"]
+ staff_group = data["groups"][4]
+ assert staff_group["members"] == 2 and staff_group["weight"] == 200
+ assert [p["name"] for p in data["plugins"]] == ["Essentials", "LibsDisguises"]
+ essentials = data["plugins"][0]
+ assert essentials["version"] == "2.22.0"
+ assert [c["name"] for c in essentials["commands"]] == ["kick", "me"]
+ # default, commoner, noble, helper, staff, helper_player, helper_inactive
+ assert essentials["commands"][0]["access"] == "nnnyynn"
+ assert essentials["commands"][1] == {"name": "me", "aliases": ["eme"], "description": "Describes an action.",
+ "usage": None, "permission": "essentials.me", "shadowed": False,
+ "access": "yyyyyyy"}
+ shadowed = data["plugins"][1]["commands"][1]
+ assert shadowed["shadowed"] is True and shadowed["access"] is None
+
+
+def test_groups_missing_from_the_mirror_still_show(app, staff, env):
+ plugin = client(app)
+ payload = catalogue(groups=["ghost"], commands=[command("me", permission="essentials.me", access="y")])
+ assert plugin.put("/luckperms/plugin/commands", headers=PLUGIN, json=payload).status_code == 200
+ data = client(app, staff["mod"]).get("/admin/luckperms/commands").json()
+ assert data["groups"] == [{"name": "ghost", "display_name": None, "weight": None, "members": 0, "kind": "rank",
+ "track": None, "parents": [], "nodes": []}]
+
+
+def test_old_lists_are_marked_stale(app, staff, env):
+ assert client(app).put("/luckperms/plugin/commands", headers=PLUGIN, json=catalogue()).status_code == 200
+ with env.connect() as conn:
+ conn.execute("UPDATE lp_commands SET received_at = '2026-01-01T00:00:00Z'")
+ conn.commit()
+ assert client(app, staff["mod"]).get("/admin/luckperms/commands").json()["status"]["fresh"] is False
+
+
+def test_players_cannot_read_commands(app, env):
+ from src.luckperms.test_luckperms import account
+ player = account(env, "pat")
+ assert client(app, player).get("/admin/luckperms/commands").status_code == 403
+ assert client(app).get("/admin/luckperms/commands").status_code == 401
+
+
+def test_empty_tracks_are_ignored():
+ assert commands._kinds([{"name": "empty", "groups": []}, {"name": "t", "groups": ["a", "b"]}]) == {
+ "b": ("staff", "t"), "a": ("step", "t")}
diff --git a/backend/src/skins/schema.sql b/backend/src/skins/schema.sql
index 8858d4a5..8624a659 100644
--- a/backend/src/skins/schema.sql
+++ b/backend/src/skins/schema.sql
@@ -851,6 +851,17 @@ CREATE TABLE IF NOT EXISTS lp_user_nodes (
CREATE INDEX IF NOT EXISTS idx_lp_user_nodes_uuid ON lp_user_nodes(uuid);
CREATE INDEX IF NOT EXISTS idx_lp_user_nodes_key ON lp_user_nodes(key);
+-- The server's commands and each group's access, as TFMCWeb last published
+-- them for the Commands tab; see src/luckperms/commands.py.
+CREATE TABLE IF NOT EXISTS lp_commands (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ server TEXT,
+ hash TEXT,
+ generated_at INTEGER,
+ received_at TEXT,
+ payload TEXT NOT NULL
+);
+
-- Changes staff queued for the bridge. Rows are kept as the change history.
CREATE TABLE IF NOT EXISTS lp_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
From 1ca696632ab0b17c4205218cd32f0bd0736bf440 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Thu, 15 Oct 2026 15:25:19 +0000
Subject: [PATCH 2/4] Add the Commands tab to the staff panel
Co-Authored-By: GPT-6.1-Sol
---
frontend/app/admin/commands/page.tsx | 18 +
.../app/components/admin/AdminNav.test.tsx | 10 +
frontend/app/components/admin/AdminNav.tsx | 1 +
.../admin/commands/CommandMatrix.tsx | 309 ++++++++++++++++
.../admin/commands/CommandRoles.tsx | 346 ++++++++++++++++++
.../admin/commands/CommandsOverview.test.tsx | 235 ++++++++++++
.../admin/commands/CommandsOverview.tsx | 222 +++++++++++
.../app/components/admin/commands/parts.tsx | 217 +++++++++++
.../components/profile/ProfilePanel.test.tsx | 9 +-
frontend/lib/admin/commands.test.ts | 134 +++++++
frontend/lib/admin/commands.ts | 178 +++++++++
11 files changed, 1678 insertions(+), 1 deletion(-)
create mode 100644 frontend/app/admin/commands/page.tsx
create mode 100644 frontend/app/components/admin/commands/CommandMatrix.tsx
create mode 100644 frontend/app/components/admin/commands/CommandRoles.tsx
create mode 100644 frontend/app/components/admin/commands/CommandsOverview.test.tsx
create mode 100644 frontend/app/components/admin/commands/CommandsOverview.tsx
create mode 100644 frontend/app/components/admin/commands/parts.tsx
create mode 100644 frontend/lib/admin/commands.test.ts
create mode 100644 frontend/lib/admin/commands.ts
diff --git a/frontend/app/admin/commands/page.tsx b/frontend/app/admin/commands/page.tsx
new file mode 100644
index 00000000..4b226a2e
--- /dev/null
+++ b/frontend/app/admin/commands/page.tsx
@@ -0,0 +1,18 @@
+import AdminColumn from "../../components/admin/AdminColumn";
+import CommandsOverview from "../../components/admin/commands/CommandsOverview";
+import { parseCommandsState } from "@/lib/admin/commands";
+
+type SearchParams = Promise>;
+
+export default async function AdminCommandsPage({ searchParams }: { searchParams: SearchParams }) {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(await searchParams)) {
+ const first = Array.isArray(value) ? value[0] : value;
+ if (first !== undefined) params.set(key, first);
+ }
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/components/admin/AdminNav.test.tsx b/frontend/app/components/admin/AdminNav.test.tsx
index cc18cd89..7222aa97 100644
--- a/frontend/app/components/admin/AdminNav.test.tsx
+++ b/frontend/app/components/admin/AdminNav.test.tsx
@@ -17,6 +17,7 @@ it("puts each staff path under its tab, sub-pages included", () => {
expect(adminSection("/admin/movement")).toBe("movement");
expect(adminSection("/admin/rail")).toBe("rail");
expect(adminSection("/admin/ranks/groups/staff")).toBe("ranks");
+ expect(adminSection("/admin/commands")).toBe("commands");
expect(adminSection("/admin/precedent")).toBe("precedent");
expect(adminSection("/admin/codes")).toBe("codes");
expect(adminSection("/admin/servers")).toBe("servers");
@@ -31,3 +32,12 @@ it("marks the tab for the current path", () => {
expect(screen.getByRole("link", { name: "Ranks" }).getAttribute("aria-current")).toBe("page");
expect(screen.getByRole("link", { name: "Accounts" }).getAttribute("aria-current")).toBeNull();
});
+
+it("places Commands after Ranks and marks it current", () => {
+ pathname.current = "/admin/commands";
+ render( );
+ const links = screen.getAllByRole("link");
+ const ranksIndex = links.findIndex((link) => link.textContent === "Ranks");
+ expect(links[ranksIndex + 1].textContent).toBe("Commands");
+ expect(links[ranksIndex + 1].getAttribute("aria-current")).toBe("page");
+});
diff --git a/frontend/app/components/admin/AdminNav.tsx b/frontend/app/components/admin/AdminNav.tsx
index 8f6a16e5..5e0932ac 100644
--- a/frontend/app/components/admin/AdminNav.tsx
+++ b/frontend/app/components/admin/AdminNav.tsx
@@ -10,6 +10,7 @@ const LINKS = [
{ href: "/admin/movement", label: "Movement", key: "movement" },
{ href: "/admin/rail", label: "Rail", key: "rail" },
{ href: "/admin/ranks", label: "Ranks", key: "ranks" },
+ { href: "/admin/commands", label: "Commands", key: "commands" },
{ href: "/admin/precedent", label: "Precedent", key: "precedent" },
{ href: "/admin/codes", label: "Codes", key: "codes" },
{ href: "/admin/servers", label: "Servers", key: "servers" },
diff --git a/frontend/app/components/admin/commands/CommandMatrix.tsx b/frontend/app/components/admin/commands/CommandMatrix.tsx
new file mode 100644
index 00000000..1d74e110
--- /dev/null
+++ b/frontend/app/components/admin/commands/CommandMatrix.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import { Fragment, useState } from "react";
+import {
+ GROUP_SECTIONS,
+ commandCounts,
+ commandLabel,
+ commandState,
+ filterPlugins,
+ groupLabel,
+ matchesCommand,
+ matchesFilter,
+ totalCommands,
+ type CommandFilter,
+ type CommandsSnapshot,
+ type CommandsUrlState,
+} from "@/lib/admin/commands";
+import { inputClass, quietButtonClass } from "../ui";
+import {
+ Caret,
+ CommandDetail,
+ EmptyResults,
+ Legend,
+ StateMark,
+ commandPanelClass,
+ focusClass,
+ footnote,
+ lineClass,
+} from "./parts";
+
+const FILTERS: { id: CommandFilter; label: string }[] = [
+ { id: "all", label: "All" },
+ { id: "player", label: "Player allowed" },
+ { id: "staff", label: "Staff only" },
+ { id: "open", label: "No permission set" },
+];
+
+export default function CommandMatrix({
+ data,
+ state,
+ onChange,
+ onReset,
+}: {
+ data: CommandsSnapshot;
+ state: CommandsUrlState;
+ onChange: (patch: Partial) => void;
+ onReset: () => void;
+}) {
+ const [expanded, setExpanded] = useState>({});
+ const [openRow, setOpenRow] = useState(null);
+ const sections = GROUP_SECTIONS.map((section) => ({
+ ...section,
+ groups: data.groups
+ .map((group, index) => ({ group, index }))
+ .filter(({ group }) => group.kind === section.kind && (state.steps || group.kind !== "step")),
+ })).filter((section) => section.groups.length);
+ const columns = sections.flatMap((section) => section.groups);
+ const base = filterPlugins(
+ data.plugins,
+ (command, plugin) => (!state.plugin || plugin.name === state.plugin) && matchesCommand(command, plugin, state.q),
+ );
+ const list = filterPlugins(base, (command) => matchesFilter(command, data.groups, state.filter));
+ const isOpen = (name: string) => expanded[name] ?? Boolean(state.q.trim() || state.plugin === name);
+ const allOpen = list.every((plugin) => isOpen(plugin.name));
+ const count = totalCommands(list);
+ const filtered = Boolean(state.q || state.filter !== "all" || state.plugin);
+ const boundary = (index: number) =>
+ index > 0 && columns[index].group.kind !== columns[index - 1].group.kind ? `border-l ${lineClass}` : "";
+
+ return (
+ <>
+
+
+ {FILTERS.map((filter) => (
+ matchesFilter(command, data.groups, filter.id)))}`}
+ aria-pressed={state.filter === filter.id}
+ onClick={() => onChange({ filter: filter.id })}
+ className={`${focusClass} flex min-h-8 items-center gap-2 rounded-sm border px-2 text-xs ${state.filter === filter.id ? `border-[var(--tfmc-accent)]/20 bg-[var(--tfmc-moss)] text-[var(--tfmc-cream)]` : "border-transparent text-[var(--tfmc-mist)] hover:text-[var(--tfmc-cream)]"}`}
+ >
+ {filter.label}
+
+ {totalCommands(filterPlugins(base, (command) => matchesFilter(command, data.groups, filter.id)))}
+
+
+ ))}
+
+
+
+
+
+
+ {count} {" "}
+ {filtered ? `of ${totalCommands(data.plugins)} ` : ""}commands · {list.length}{" "}
+ {list.length === 1 ? "plugin" : "plugins"}
+
+
+
+ Plugin
+ {
+ setExpanded({});
+ onChange({ plugin: event.target.value });
+ }}
+ className={`${inputClass} max-w-44 !py-1 !text-xs`}
+ >
+ All plugins
+ {data.plugins.map((plugin) => (
+
+ {plugin.name}
+
+ ))}
+
+
+ {list.length > 0 ? (
+ setExpanded(Object.fromEntries(list.map((plugin) => [plugin.name, !allOpen])))}
+ >
+ {allOpen ? "Collapse all" : "Expand all"}
+
+ ) : null}
+
+
+ {!list.length ? (
+
+ ) : (
+ <>
+
+ Command column stays fixed
+ Scroll ranks →
+
+
+
+
+ Commands grouped by plugin, with command permission results for each role.
+
+
+
+ {columns.map(({ group }) => (
+
+ ))}
+
+
+
+
+ Plugin / command
+
+ {sections.map((section, index) => (
+
+ {section.label}
+
+ ))}
+
+
+ {columns.map(({ group }, index) => (
+
+ {groupLabel(group)}
+
+ {group.members} {group.members === 1 ? "member" : "members"}
+
+
+ ))}
+
+
+
+ {list.map((plugin) => (
+
+
+
+
+ setExpanded((previous) => ({ ...previous, [plugin.name]: !isOpen(plugin.name) }))
+ }
+ className={`${focusClass} flex min-h-[42px] w-full items-center gap-2 px-3.5 py-2 text-left`}
+ >
+
+ {plugin.name}
+
+ {plugin.commands.length} commands
+
+
+
+ {columns.map(({ group, index }, column) => {
+ const counts = commandCounts(plugin.commands, index);
+ const uniform = (["y", "n", "o"] as const).find(
+ (state) => counts[state] === plugin.commands.length,
+ );
+ return (
+
+ {uniform ? (
+
+ ) : (
+
+ {counts.y}
+
+ {" "}
+ / {plugin.commands.length}
+
+
+ )}
+
+ );
+ })}
+
+ {isOpen(plugin.name)
+ ? plugin.commands.map((command) => {
+ const id = `${plugin.name}/${command.name}`;
+ const open = openRow === id;
+ return (
+
+
+
+ setOpenRow(open ? null : id)}
+ className={`${focusClass} flex min-h-[35px] w-full items-center gap-2 px-4 py-1.5 text-left sm:pl-8`}
+ >
+
+
+ {commandLabel(command, plugin)}
+
+ {command.aliases.length ? (
+
+ {command.aliases.length} {command.aliases.length === 1 ? "alias" : "aliases"}
+
+ ) : null}
+
+
+ {columns.map(({ group, index }, column) => (
+
+
+
+ ))}
+
+ {open ? (
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+ })
+ : null}
+
+ ))}
+
+
+
+ >
+ )}
+
+ {footnote}
+ Plugin counts show allowed / total.
+
+
+ >
+ );
+}
diff --git a/frontend/app/components/admin/commands/CommandRoles.tsx b/frontend/app/components/admin/commands/CommandRoles.tsx
new file mode 100644
index 00000000..2e1dfcf0
--- /dev/null
+++ b/frontend/app/components/admin/commands/CommandRoles.tsx
@@ -0,0 +1,346 @@
+"use client";
+
+import { Fragment, useState } from "react";
+import {
+ GROUP_SECTIONS,
+ commandCounts,
+ commandLabel,
+ commandState,
+ filterPlugins,
+ groupLabel,
+ inheritanceChain,
+ isNewCommand,
+ matchesCommand,
+ previousGroup,
+ totalCommands,
+ type CommandGroup,
+ type CommandsSnapshot,
+ type CommandsUrlState,
+} from "@/lib/admin/commands";
+import { expiryLabel, shortContextLabel, type LpNode } from "@/lib/admin/luckperms";
+import { formatEpoch } from "@/lib/admin/time";
+import { badgeClass, headingClass, inputClass, rowClass } from "../ui";
+import {
+ Caret,
+ CommandDetail,
+ EmptyResults,
+ GroupLink,
+ NewBadge,
+ StateMark,
+ commandPanelClass,
+ focusClass,
+ footnote,
+ lineClass,
+ stateLabels,
+} from "./parts";
+
+export default function CommandRoles({
+ data,
+ state,
+ onChange,
+ onReset,
+}: {
+ data: CommandsSnapshot;
+ state: CommandsUrlState;
+ onChange: (patch: Partial) => void;
+ onReset: () => void;
+}) {
+ const [openRow, setOpenRow] = useState(null);
+ const sections = GROUP_SECTIONS.map((section) => ({
+ ...section,
+ groups: data.groups.filter((group) => group.kind === section.kind && (state.steps || group.kind !== "step")),
+ })).filter((section) => section.groups.length);
+ const visible = sections.flatMap((section) => section.groups);
+ const group = visible.find((item) => item.name === state.role) ?? visible[0];
+ if (!group) return No roles in this command list.
;
+ const index = data.groups.indexOf(group);
+ const previous = previousGroup(data.groups, group);
+ const previousIndex = previous ? data.groups.indexOf(previous) : -1;
+ const commands = data.plugins.flatMap((plugin) => plugin.commands);
+ const counts = commandCounts(commands, index);
+ const added = commands.filter((command) => isNewCommand(command, index, previousIndex)).length;
+ const chain = inheritanceChain(group, data.groups);
+ const list = filterPlugins(
+ data.plugins,
+ (command, plugin) =>
+ commandState(command, index) === state.state &&
+ (!state.onlyNew || isNewCommand(command, index, previousIndex)) &&
+ matchesCommand(command, plugin, state.q),
+ );
+ const selectRole = (role: string) => {
+ setOpenRow(null);
+ onChange({ role, onlyNew: false });
+ };
+
+ return (
+
+
+
+ Role
+ Members
+
+ {sections.map((section) => (
+
+
{section.label}
+ {section.groups.map((item) => (
+ selectRole(item.name)}
+ className={`${focusClass} flex min-h-[37px] w-full items-center justify-between gap-2 rounded-sm border-l-2 px-2 py-1.5 text-left text-[13px] ${item.name === group.name ? "border-[var(--tfmc-accent)] bg-[var(--tfmc-moss)] text-[var(--tfmc-cream)]" : "border-transparent text-[var(--tfmc-stone)] hover:bg-[var(--tfmc-forest)]"}`}
+ >
+ {groupLabel(item)}
+
+ {item.members}
+
+
+ ))}
+
+ ))}
+
+
+
+ Role
+ selectRole(event.target.value)}
+ className={`${inputClass} w-[230px] max-w-[80%]`}
+ >
+ {sections.map((section) => (
+
+ {section.groups.map((item) => (
+
+ {groupLabel(item)} · {item.members} members
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+ {group.members} {group.members === 1 ? "member" : "members"}
+
+
+ {previous ? (
+
+ +{added} vs {groupLabel(previous)}
+
+ ) : null}
+
+
+ Inheritance
+ {chain.length ? (
+ chain.map((name, i) => {
+ const parent = data.groups.find((item) => item.name === name);
+ return (
+
+ {i > 0 ? (
+
+ ›
+
+ ) : null}
+ {parent ? : {name} }
+
+ );
+ })
+ ) : (
+ None
+ )}
+
+
+ {(["y", "n", "o"] as const).map((value, i) => (
+ onChange({ state: value, onlyNew: false })}
+ className={`${focusClass} border-b-2 px-2 py-3 text-left sm:px-4 ${i ? `border-l ${lineClass}` : ""} ${state.state === value ? "border-b-[var(--tfmc-accent)] bg-[color-mix(in_srgb,var(--tfmc-moss)_30%,transparent)]" : "border-b-transparent"}`}
+ >
+
+
+ {counts[value]}
+
+ {stateLabels[value]}
+
+ ))}
+
+
+
+ {state.q || state.onlyNew
+ ? `${totalCommands(list)} commands`
+ : state.state === "y"
+ ? "Allowed commands"
+ : state.state === "n"
+ ? "Hidden commands"
+ : "No permission set"}
+
+ {state.state === "y" && previous ? (
+
+ onChange({ onlyNew: event.target.checked })}
+ className={`${focusClass} accent-[var(--tfmc-accent)] [color-scheme:dark]`}
+ />
+ Only new at this rank +{added}
+
+ ) : (
+ {totalCommands(list)} commands
+ )}
+
+
+ {!list.length ? (
+
+ ) : (
+ list.map((plugin) => {
+ const newCount =
+ state.state === "y"
+ ? plugin.commands.filter((command) => isNewCommand(command, index, previousIndex)).length
+ : 0;
+ return (
+
+
+
+
+
+ {plugin.name}
+
+ {plugin.version}
+
+
+ {plugin.commands.length}
+
+ {newCount > 0 ? +{newCount} new : null}
+
+ {plugin.commands.map((command) => {
+ const id = `${group.name}/${plugin.name}/${command.name}`;
+ const open = openRow === id;
+ const isNew = state.state === "y" && isNewCommand(command, index, previousIndex);
+ return (
+
+
+ setOpenRow(open ? null : id)}
+ className={`${focusClass} flex min-w-0 flex-1 basis-40 items-center gap-2 text-left sm:flex-none sm:basis-[235px]`}
+ >
+
+
+ {commandLabel(command, plugin)}
+
+
+
+ {command.description}
+
+ {isNew ? (
+
+ New
+
+ ) : null}
+
+ {open ? : null}
+
+ );
+ })}
+
+ );
+ })
+ )}
+
{footnote}
+
+
+
+
+ );
+}
+
+function OwnPermissions({ group }: { group: CommandGroup }) {
+ const granted = group.nodes.filter((node) => node.value);
+ const denied = group.nodes.filter((node) => !node.value);
+ return (
+
+
+
+
+
+ Own permissions
+
+ {granted.length} granted · {denied.length} denied
+
+
+
+
+ Set directly on {groupLabel(group)}. Inherited permissions are shown on their source roles.
+
+
+ {[
+ { label: "Granted", nodes: granted },
+ { label: "Denied", nodes: denied },
+ ].map(({ label, nodes }) => (
+
+
+ {label} · {nodes.length}
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function NodeList({ nodes }: { nodes: LpNode[] }) {
+ if (!nodes.length) return None
;
+ return (
+
+ {nodes.map((node, index) => (
+
+ {node.key}
+
+ {Object.entries(node.contexts).flatMap(([key, values]) =>
+ values.map((value) => (
+
+ {shortContextLabel({ [key]: [value] })}
+
+ )),
+ )}
+ {node.expiry ? (
+
+ {expiryLabel(node.expiry)}
+
+ ) : null}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/app/components/admin/commands/CommandsOverview.test.tsx b/frontend/app/components/admin/commands/CommandsOverview.test.tsx
new file mode 100644
index 00000000..e6fcfbb4
--- /dev/null
+++ b/frontend/app/components/admin/commands/CommandsOverview.test.tsx
@@ -0,0 +1,235 @@
+/** @vitest-environment jsdom */
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { AccountApiError } from "@/lib/account/api";
+import {
+ getCommands,
+ parseCommandsState,
+ type CommandGroup,
+ type CommandsSnapshot,
+ type RegisteredCommand,
+} from "@/lib/admin/commands";
+import { resetUrlWrites } from "@/lib/admin/urlState";
+import CommandsOverview from "./CommandsOverview";
+
+vi.mock("@/lib/admin/commands", async (original) => ({
+ ...(await original()),
+ getCommands: vi.fn(),
+}));
+
+function snapshot(): CommandsSnapshot {
+ const group = (
+ name: string,
+ kind: CommandGroup["kind"],
+ parents: string[] = [],
+ display_name: string | null = null,
+ ): CommandGroup => ({ name, kind, parents, display_name, members: 7, track: null, weight: null, nodes: [] });
+ const command = (name: string, access: string | null, extra: Partial = {}): RegisteredCommand => ({
+ name,
+ access,
+ aliases: [],
+ permission: access === null ? null : `test.${name}`,
+ description: `${name} description`,
+ usage: null,
+ shadowed: false,
+ ...extra,
+ });
+ return {
+ status: {
+ server: "main",
+ generated_at: Math.floor(Date.now() / 1000) - 7200,
+ received_at: new Date().toISOString(),
+ fresh: true,
+ },
+ groups: [
+ group("default", "rank", [], "Player"),
+ group("helper_player", "step", ["default"]),
+ group("commoner", "rank", ["default"]),
+ group("noble", "rank", ["commoner"]),
+ group("helper", "staff", ["helper_player"]),
+ ],
+ plugins: [
+ {
+ name: "Essentials",
+ version: "2.0",
+ commands: [
+ command("anvil", "yyyyy", { aliases: ["repair"], usage: "/anvil [item]" }),
+ command("help", null),
+ command("list", "yyyyy", { shadowed: true }),
+ ],
+ },
+ { name: "ItemsAdder", version: "4.0", commands: [command("emote", "nnnyy")] },
+ { name: "Moderation", version: null, commands: [command("ban", "nynny"), command("hidden", "nnnnn")] },
+ ],
+ };
+}
+
+beforeEach(() => {
+ window.history.replaceState(null, "", "/admin/commands");
+ vi.mocked(getCommands).mockResolvedValue(snapshot());
+});
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ resetUrlWrites();
+});
+
+it("loads the matrix, labels API groups, expands commands and shows details and shadowed labels", async () => {
+ render( );
+ const matrix = await screen.findByRole("region", { name: "Commands by role" });
+ expect(screen.getByText("6 commands · 3 plugins")).toBeTruthy();
+ expect(
+ screen
+ .getByText(/Published/)
+ .querySelector("time")
+ ?.getAttribute("title"),
+ ).toBeTruthy();
+ expect(within(matrix).getByRole("columnheader", { name: /Player.*7 members/ })).toBeTruthy();
+ expect(within(matrix).queryByText("helper_player")).toBeNull();
+ fireEvent.click(within(matrix).getByRole("button", { name: "Essentials 3 commands" }));
+ expect(screen.getByRole("button", { name: "/essentials:list" })).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "/anvil" }));
+ expect(screen.getByText("/anvil [item]")).toBeTruthy();
+ expect(screen.getByText("/repair")).toBeTruthy();
+ expect(screen.getByText("test.anvil")).toBeTruthy();
+});
+
+it("searches aliases and permissions, saves URL state and clears an empty search", async () => {
+ render( );
+ const search = await screen.findByRole("searchbox");
+ fireEvent.click(screen.getByRole("button", { name: "Expand all" }));
+ fireEvent.click(screen.getByRole("button", { name: "Collapse all" }));
+ fireEvent.change(search, { target: { value: "/repair" } });
+ expect(screen.getByRole("button", { name: "/anvil" })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "ItemsAdder 1 commands" })).toBeNull();
+ expect(new URLSearchParams(window.location.search).get("q")).toBe("/repair");
+ fireEvent.change(search, { target: { value: "test.emote" } });
+ expect(screen.getByRole("button", { name: "/emote" })).toBeTruthy();
+ fireEvent.change(search, { target: { value: "no-match" } });
+ expect(screen.getByText("No commands found")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
+ expect((search as HTMLInputElement).value).toBe("");
+});
+
+it("filters matrix permissions and plugins and toggles track steps without shifting access indexes", async () => {
+ render( );
+ await screen.findByRole("searchbox");
+ fireEvent.click(screen.getByRole("button", { name: "Staff only 1" }));
+ expect(within(screen.getByRole("region", { name: "Commands by role" })).getByRole("status").textContent).toBe(
+ "1 of 6 commands · 1 plugin",
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Expand all" }));
+ const banRow = screen.getByRole("button", { name: "/ban" }).closest("tr")!;
+ expect(within(banRow).getAllByRole("img", { name: "Allowed by command permission" })).toHaveLength(1);
+ fireEvent.click(screen.getByRole("checkbox", { name: "Show track steps" }));
+ expect(screen.getByRole("columnheader", { name: "Track steps" })).toBeTruthy();
+ expect(within(banRow).getAllByRole("img", { name: "Allowed by command permission" })).toHaveLength(2);
+ fireEvent.click(screen.getByRole("button", { name: "All 6" }));
+ fireEvent.change(screen.getByRole("combobox", { name: "Plugin" }), { target: { value: "ItemsAdder" } });
+ expect(screen.getByRole("button", { name: "/emote" })).toBeTruthy();
+ expect(new URLSearchParams(window.location.search).get("plugin")).toBe("ItemsAdder");
+});
+
+it("shows the role view, inheritance, new commands and own permission contexts and expiry", async () => {
+ const data = snapshot();
+ data.groups[3].nodes = [
+ {
+ key: "ia.emote",
+ value: true,
+ contexts: { server: ["main"], world: ["spawn"] },
+ expiry: Math.floor(Date.now() / 1000) + 7200,
+ },
+ { key: "test.deny", value: false, contexts: {}, expiry: 0 },
+ ];
+ vi.mocked(getCommands).mockResolvedValue(data);
+ render( );
+ const role = await screen.findByRole("region", { name: "noble commands" });
+ expect(within(role).getByRole("link", { name: "noble" }).getAttribute("href")).toBe("/admin/ranks/groups/noble");
+ expect(within(role).getByText("vs commoner", { exact: false }).textContent).toBe("+1 vs commoner");
+ expect(
+ within(role)
+ .getAllByRole("link")
+ .map((link) => link.textContent),
+ ).toEqual(["noble", "commoner", "Player"]);
+ fireEvent.click(screen.getByRole("checkbox", { name: /Only new at this rank/ }));
+ expect(within(role).getByRole("button", { name: /\/emote/ })).toBeTruthy();
+ expect(within(role).queryByRole("button", { name: /\/anvil/ })).toBeNull();
+ fireEvent.click(within(role).getByText("Own permissions"));
+ expect(within(role).getByTitle("server=main").textContent).toBe("Main");
+ expect(within(role).getByText("world=spawn")).toBeTruthy();
+ expect(within(role).getByText("Expires in 2 h")).toBeTruthy();
+ expect(within(role).getByText("test.deny")).toBeTruthy();
+ expect(new URLSearchParams(window.location.search).get("new")).toBe("1");
+});
+
+it("changes roles, searches role commands, filters states, reveals steps and reviews commands without permission in the matrix", async () => {
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "By role" }));
+ fireEvent.click(screen.getByRole("button", { name: "noble 7 members" }));
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "emote" } });
+ expect(screen.getByRole("button", { name: /\/emote/ })).toBeTruthy();
+ expect(new URLSearchParams(window.location.search).get("role")).toBe("noble");
+ fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
+ const role = screen.getByRole("region", { name: "noble commands" });
+ fireEvent.click(within(role).getByRole("button", { name: /No permission set/ }));
+ expect(within(role).getByRole("button", { name: /\/help/ })).toBeTruthy();
+ fireEvent.click(within(role).getByRole("button", { name: /Hidden/ }));
+ expect(within(role).queryByRole("button", { name: /\/emote/ })).toBeNull();
+ expect(screen.queryByRole("button", { name: "helper_player 7 members" })).toBeNull();
+ fireEvent.click(screen.getByRole("checkbox", { name: "Show track steps" }));
+ fireEvent.change(screen.getByRole("combobox", { name: "Role" }), { target: { value: "helper_player" } });
+ expect(screen.getByRole("region", { name: "helper_player commands" })).toBeTruthy();
+ fireEvent.click(screen.getByRole("checkbox", { name: "Show track steps" }));
+ expect(screen.getByRole("region", { name: "Player commands" })).toBeTruthy();
+ expect(new URLSearchParams(window.location.search).get("role")).toBe("default");
+ fireEvent.click(screen.getByRole("button", { name: "Review" }));
+ expect(screen.getByRole("region", { name: "Commands by role" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "No permission set 1" }).getAttribute("aria-pressed")).toBe("true");
+});
+
+it("defaults phones to roles while respecting an explicit matrix URL", async () => {
+ vi.stubGlobal("matchMedia", () => ({ matches: true }));
+ render( );
+ expect(await screen.findByRole("region", { name: "Player commands" })).toBeTruthy();
+ cleanup();
+ render( );
+ expect(await screen.findByRole("region", { name: "Commands by role" })).toBeTruthy();
+});
+
+it("restores URL state on browser history navigation", async () => {
+ render( );
+ await screen.findByRole("searchbox");
+ window.history.replaceState(null, "", "/admin/commands?view=roles&role=noble&q=emote&steps=1");
+ fireEvent.popState(window);
+ expect(screen.getByRole("region", { name: "noble commands" })).toBeTruthy();
+ expect((screen.getByRole("searchbox") as HTMLInputElement).value).toBe("emote");
+ expect((screen.getByRole("checkbox", { name: "Show track steps" }) as HTMLInputElement).checked).toBe(true);
+});
+
+it("shows missing and stale command lists", async () => {
+ vi.mocked(getCommands).mockResolvedValue({ status: null, groups: [], plugins: [] });
+ render( );
+ expect(
+ await screen.findByText("No command list yet. TFMCWeb publishes it when the LuckPerms bridge is on."),
+ ).toBeTruthy();
+ expect(screen.queryByRole("searchbox")).toBeNull();
+ cleanup();
+ const data = snapshot();
+ data.status!.fresh = false;
+ vi.mocked(getCommands).mockResolvedValue(data);
+ render( );
+ expect(await screen.findByText("Command list is stale · published 2 h ago.")).toBeTruthy();
+});
+
+it.each([
+ [401, "Sign in with Discord"],
+ [403, "This page is for TFMC staff only."],
+ [503, "The staff panel isn’t available yet."],
+ [500, "We couldn’t load the staff panel. Please refresh the page."],
+])("uses StaffGate for HTTP %s", async (status, message) => {
+ vi.mocked(getCommands).mockRejectedValue(new AccountApiError("failed", status as number));
+ render( );
+ expect(await screen.findByText(message as string)).toBeTruthy();
+ expect(screen.queryByRole("searchbox")).toBeNull();
+});
diff --git a/frontend/app/components/admin/commands/CommandsOverview.tsx b/frontend/app/components/admin/commands/CommandsOverview.tsx
new file mode 100644
index 00000000..d4370210
--- /dev/null
+++ b/frontend/app/components/admin/commands/CommandsOverview.tsx
@@ -0,0 +1,222 @@
+"use client";
+
+import { useEffect, useState, useSyncExternalStore } from "react";
+import LoadingSkeleton from "@/app/components/shared/LoadingSkeleton";
+import {
+ commandsUrl,
+ getCommands,
+ parseCommandsState,
+ totalCommands,
+ type CommandsSnapshot,
+ type CommandsUrlState,
+} from "@/lib/admin/commands";
+import { formatAgo, formatEpoch } from "@/lib/admin/time";
+import { writeUrl } from "@/lib/admin/urlState";
+import { StaffGateMessage, gateKind, type GateKind } from "../StaffGate";
+import { headingClass, inputClass, quietButtonClass, warnClass } from "../ui";
+import CommandMatrix from "./CommandMatrix";
+import CommandRoles from "./CommandRoles";
+import { Icon, StateMark, commandPanelClass, focusClass, lineClass } from "./parts";
+
+type Load = { kind: "loading" } | { kind: GateKind } | { kind: "ready"; data: CommandsSnapshot };
+const defaultState = () => parseCommandsState(new URLSearchParams());
+const phoneQuery = "(max-width: 767px)";
+const phoneSnapshot = () => window.matchMedia?.(phoneQuery).matches ?? false;
+function subscribePhone(onChange: () => void) {
+ const media = window.matchMedia?.(phoneQuery);
+ media?.addEventListener?.("change", onChange);
+ return () => media?.removeEventListener?.("change", onChange);
+}
+
+export default function CommandsOverview({ initialState = defaultState() }: { initialState?: CommandsUrlState }) {
+ const [load, setLoad] = useState({ kind: "loading" });
+ const [state, setState] = useState(initialState);
+ const phone = useSyncExternalStore(subscribePhone, phoneSnapshot, () => false);
+ const view = state.view ?? (phone ? "roles" : "matrix");
+ const [now, setNow] = useState(() => Date.now() / 1000);
+
+ useEffect(() => {
+ let active = true;
+ getCommands()
+ .then((data) => {
+ if (active) setLoad({ kind: "ready", data });
+ })
+ .catch((error) => {
+ if (active) setLoad({ kind: gateKind(error) });
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ const onPopState = () => setState(parseCommandsState(new URLSearchParams(window.location.search)));
+ window.addEventListener("popstate", onPopState);
+ const timer = setInterval(() => setNow(Date.now() / 1000), 60_000);
+ return () => {
+ window.removeEventListener("popstate", onPopState);
+ clearInterval(timer);
+ };
+ }, []);
+
+ function change(patch: Partial) {
+ const next = { ...state, view, ...patch };
+ setState(next);
+ writeUrl(commandsUrl(new URL(window.location.href), next), true);
+ }
+ const reset = () => change({ q: "", filter: "all", plugin: "", onlyNew: false, state: "y" });
+
+ if (load.kind === "loading") return ;
+ if (load.kind !== "ready") return ;
+ const { data } = load;
+ const status = data.status;
+ const age = status
+ ? now - status.generated_at >= 30 * 86400
+ ? `${Math.floor((now - status.generated_at) / 86400)} d ago`
+ : formatAgo(status.generated_at, now).toLowerCase()
+ : "";
+ const noPermission = data.plugins
+ .flatMap((plugin) => plugin.commands)
+ .filter((command) => command.access === null).length;
+
+ return (
+
+
+ {!status ? (
+
+ No command list yet. TFMCWeb publishes it when the LuckPerms bridge is on.
+
+ ) : (
+ <>
+ {!status.fresh ? (
+
+ Command list is stale · published {age}.
+
+ ) : null}
+
+
+
+ {(["matrix", "roles"] as const).map((value) => (
+ change({ view: value })}
+ className={`${focusClass} flex min-h-[30px] items-center justify-center gap-2 rounded-sm px-3.5 py-1 text-[13px] ${view === value ? "bg-[var(--tfmc-moss)] text-[var(--tfmc-cream)]" : "text-[var(--tfmc-mist)] hover:text-[var(--tfmc-cream)]"}`}
+ >
+
+ {value === "matrix" ? "By command" : "By role"}
+
+ ))}
+
+ {data.groups.some((group) => group.kind === "step") ? (
+
+ {
+ const steps = event.target.checked;
+ const hidingRole =
+ !steps && data.groups.find((group) => group.name === state.role)?.kind === "step";
+ change(
+ hidingRole
+ ? {
+ steps,
+ role: data.groups.find((group) => group.kind !== "step")?.name ?? "",
+ onlyNew: false,
+ }
+ : { steps },
+ );
+ }}
+ className={`${focusClass} accent-[var(--tfmc-accent)] [color-scheme:dark]`}
+ />
+ Show track steps
+
+ ) : null}
+
+
+
+
+ Search commands, aliases, plugins or permissions
+
+ change({ q: event.target.value })}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") change({ q: "" });
+ }}
+ autoComplete="off"
+ spellCheck={false}
+ placeholder="Search commands, aliases or permissions…"
+ className={`${inputClass} h-10 w-full !rounded-md !pl-9 !pr-10 !text-[13px] sm:h-[38px] [&::-webkit-search-cancel-button]:appearance-none`}
+ />
+ {state.q ? (
+ change({ q: "" })}
+ >
+ ×
+
+ ) : null}
+
+
+ {noPermission > 0 ? (
+
+
+
+ {noPermission} commands have no permission set.
+ Visible to everyone; the plugin decides when run.
+
+
change({ view: "matrix", filter: "open", plugin: "", q: "", onlyNew: false })}
+ >
+ Review
+
+
+
+ ) : null}
+ {view === "matrix" ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/app/components/admin/commands/parts.tsx b/frontend/app/components/admin/commands/parts.tsx
new file mode 100644
index 00000000..a09b53f2
--- /dev/null
+++ b/frontend/app/components/admin/commands/parts.tsx
@@ -0,0 +1,217 @@
+import Link from "next/link";
+import { Fragment } from "react";
+import {
+ commandLabel,
+ commandState,
+ groupLabel,
+ type CommandGroup,
+ type CommandPlugin,
+ type CommandState,
+ type RegisteredCommand,
+} from "@/lib/admin/commands";
+import { chipClass, panelClass, quietButtonClass } from "../ui";
+
+export const commandPanelClass = `${panelClass} !mt-0 !p-0 overflow-hidden`;
+export const lineClass = "border-[color-mix(in_srgb,var(--tfmc-cream)_14%,transparent)]";
+export const focusClass =
+ "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--tfmc-accent)]";
+export const footnote = "Allowed by command permission. Plugins may apply further checks.";
+export const stateLabels = { y: "Allowed", n: "Hidden", o: "No permission set" };
+const stateDescriptions = {
+ y: "Allowed by command permission",
+ n: "Hidden from tab-complete",
+ o: "No permission set; plugin decides when run",
+};
+
+export function Icon({ kind, className = "" }: { kind: "search" | "matrix" | "roles" | "arrow"; className?: string }) {
+ return (
+
+ {kind === "search" ? (
+ <>
+
+
+ >
+ ) : kind === "matrix" ? (
+ <>
+
+
+ >
+ ) : kind === "roles" ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+}
+
+export function Caret({ open = false }: { open?: boolean }) {
+ return (
+
+
+
+ );
+}
+
+export function StateMark({ state }: { state: CommandState }) {
+ return (
+
+
+ {state === "y" ? (
+
+ ) : state === "o" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export function Legend() {
+ return (
+
+ {(["y", "n", "o"] as const).map((state) => (
+
+
+ {stateLabels[state]}
+
+ ))}
+
+ );
+}
+
+export function NewBadge({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
+
+export function EmptyResults({ onReset }: { onReset: () => void }) {
+ return (
+
+
+
No commands found
+
No commands match these filters.
+
+ Clear filters
+
+
+ );
+}
+
+export function GroupLink({ group, className = "" }: { group: CommandGroup; className?: string }) {
+ return (
+
+ {groupLabel(group)}
+
+ );
+}
+
+export function CommandDetail({
+ command,
+ plugin,
+ groups,
+}: {
+ command: RegisteredCommand;
+ plugin: CommandPlugin;
+ groups: CommandGroup[];
+}) {
+ const allowed = groups.filter((_, index) => commandState(command, index) === "y");
+ const fields: [string, React.ReactNode][] = [
+ ["Permission", command.permission ? {command.permission} : "No permission set"],
+ ...(command.shadowed
+ ? [
+ [
+ "Command",
+
+ {commandLabel(command, plugin)}
+ ,
+ ] as [string, React.ReactNode],
+ ]
+ : []),
+ ...(command.usage ? [["Usage", {command.usage}] as [string, React.ReactNode]] : []),
+ ...(command.aliases.length
+ ? [
+ [
+ "Aliases",
+
+ {command.aliases.map((alias) => (
+ /{alias}
+ ))}
+ ,
+ ] as [string, React.ReactNode],
+ ]
+ : []),
+ [
+ command.access === null ? "Offered to" : "Allowed for",
+ command.access === null
+ ? "Everyone"
+ : allowed.length === groups.length
+ ? "All roles"
+ : allowed.map(groupLabel).join(", ") || "No listed roles",
+ ],
+ [
+ "Plugin",
+
+ {plugin.name} {plugin.version}
+ ,
+ ],
+ ];
+ return (
+
+
{command.description || "No description supplied."}
+
+ {fields.map(([label, value]) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/app/components/profile/ProfilePanel.test.tsx b/frontend/app/components/profile/ProfilePanel.test.tsx
index 1e11fe62..461bc2d9 100644
--- a/frontend/app/components/profile/ProfilePanel.test.tsx
+++ b/frontend/app/components/profile/ProfilePanel.test.tsx
@@ -1332,7 +1332,14 @@ it("opens a character's kits inside Profile, and ← returns to the character",
amount: 1,
editable: true,
kit_key: "iron_hunting_knife",
- customise: { display_name: "Exhumer's Spade", lore: [], state: "applied" },
+ customise: {
+ display_name: "Exhumer's Spade",
+ lore: [],
+ state: "applied",
+ existing_skin_id: null,
+ submission_id: null,
+ submission_status: null,
+ },
},
{ path: "m.currency.GOLD_COIN", amount: 32, editable: false },
],
diff --git a/frontend/lib/admin/commands.test.ts b/frontend/lib/admin/commands.test.ts
new file mode 100644
index 00000000..4e0409fa
--- /dev/null
+++ b/frontend/lib/admin/commands.test.ts
@@ -0,0 +1,134 @@
+import { afterEach, expect, it, vi } from "vitest";
+import {
+ commandCounts,
+ commandLabel,
+ commandsUrl,
+ commandState,
+ filterPlugins,
+ getCommands,
+ groupLabel,
+ inheritanceChain,
+ isNewCommand,
+ matchesCommand,
+ matchesFilter,
+ parseCommandsState,
+ previousGroup,
+ totalCommands,
+ type CommandGroup,
+ type CommandPlugin,
+ type RegisteredCommand,
+} from "./commands";
+import { adminRequest } from "./api";
+
+vi.mock("./api", () => ({ adminRequest: vi.fn() }));
+afterEach(() => vi.clearAllMocks());
+
+const group = (name: string, kind: CommandGroup["kind"] = "rank", parents: string[] = []): CommandGroup => ({
+ name,
+ display_name: null,
+ kind,
+ parents,
+ weight: null,
+ members: 0,
+ track: null,
+ nodes: [],
+});
+const groups = [
+ group("default"),
+ group("helper_player", "step"),
+ group("commoner"),
+ group("noble"),
+ group("helper", "staff"),
+];
+const command = (access: string | null, extra: Partial = {}): RegisteredCommand => ({
+ name: "emote",
+ aliases: ["wave"],
+ description: "Play an animation",
+ usage: "/emote [name]",
+ permission: access === null ? null : "ia.emote",
+ shadowed: false,
+ access,
+ ...extra,
+});
+const plugin: CommandPlugin = { name: "ItemsAdder", version: null, commands: [command("nnnyy"), command(null)] };
+
+it("fetches the command list through the staff API", async () => {
+ vi.mocked(adminRequest).mockResolvedValue({ status: null, groups: [], plugins: [] });
+ await getCommands();
+ expect(adminRequest).toHaveBeenCalledWith("/admin/luckperms/commands");
+});
+
+it("counts permission states separately, including commands offered to everyone", () => {
+ expect(commandCounts([command("ynnyy"), command("nnnny"), command(null)], 3)).toEqual({ y: 1, n: 1, o: 1 });
+ expect(commandState(command("y"), 9)).toBe("n");
+ expect(commandState(command(null), 9)).toBe("o");
+});
+
+it("filters using original group indexes and ignores track steps for staff-only results", () => {
+ expect(matchesFilter(command("nynny"), groups, "staff")).toBe(true);
+ expect(matchesFilter(command("nnnyy"), groups, "staff")).toBe(false);
+ expect(matchesFilter(command(null), groups, "staff")).toBe(false);
+ expect(matchesFilter(command(null), groups, "open")).toBe(true);
+ expect(matchesFilter(command("ynnnn"), groups, "player")).toBe(true);
+ expect(matchesFilter(command("nynnn"), groups, "player")).toBe(false);
+ const interleaved = [groups[1], groups[0], groups[4]];
+ expect(matchesFilter(command("nyn"), interleaved, "player")).toBe(true);
+ const filtered = filterPlugins([plugin, { ...plugin, name: "Empty", commands: [] }], (item) =>
+ matchesFilter(item, groups, "open"),
+ );
+ expect(filtered).toHaveLength(1);
+ expect(totalCommands(filtered)).toBe(1);
+ expect(plugin.commands).toHaveLength(2);
+});
+
+it("matches names, aliases, permissions, descriptions, usage and plugins without case or slash sensitivity", () => {
+ for (const query of [" /WAVE ", "IA.EMOTE", "animation", "[name]", "itemsadder", "/itemsadder:emote"]) {
+ expect(matchesCommand(command("y", { shadowed: true }), plugin, query)).toBe(true);
+ }
+ expect(matchesCommand(command(null), plugin, "missing")).toBe(false);
+ expect(commandLabel(command("y", { shadowed: true }), plugin)).toBe("/itemsadder:emote");
+ expect(groupLabel({ ...groups[0], display_name: "Player" })).toBe("Player");
+ expect(groupLabel(groups[0])).toBe("default");
+});
+
+it("compares with the preceding group in the same section rather than a parent or track step", () => {
+ expect(previousGroup(groups, groups[3])?.name).toBe("commoner");
+ expect(previousGroup(groups, groups[4])).toBeUndefined();
+ expect(isNewCommand(command("nnnyy"), 3, 2)).toBe(true);
+ expect(isNewCommand(command(null), 3, 2)).toBe(false);
+ expect(isNewCommand(command("yyyyy"), 3, -1)).toBe(false);
+});
+
+it("walks direct parents first and handles shared ancestors, unknown parents and cycles", () => {
+ const tree = [
+ group("noble", "rank", ["commoner", "patron"]),
+ group("commoner", "rank", ["default"]),
+ group("patron", "rank", ["default", "unknown"]),
+ group("default", "rank", ["noble"]),
+ ];
+ expect(inheritanceChain(tree[0], tree)).toEqual(["commoner", "patron", "default", "unknown"]);
+});
+
+it("round-trips view, role, filters and search while preserving unrelated URL state", () => {
+ const initial = parseCommandsState(
+ new URLSearchParams("view=roles&role=noble&q=emote&filter=open&state=n&plugin=ItemsAdder&new=1&steps=1"),
+ );
+ const url = new URL(
+ commandsUrl(new URL("https://example.test/admin/commands?keep=1#ranks"), initial),
+ "https://example.test",
+ );
+ expect(parseCommandsState(url.searchParams)).toEqual(initial);
+ expect(url.searchParams.get("keep")).toBe("1");
+ expect(url.hash).toBe("#ranks");
+ expect(parseCommandsState(new URLSearchParams("view=bad&filter=bad&state=bad"))).toEqual({
+ view: null,
+ role: "",
+ q: "",
+ filter: "all",
+ state: "y",
+ plugin: "",
+ onlyNew: false,
+ steps: false,
+ });
+ expect(commandsUrl(url, parseCommandsState(new URLSearchParams()))).toBe("/admin/commands?keep=1#ranks");
+});
diff --git a/frontend/lib/admin/commands.ts b/frontend/lib/admin/commands.ts
new file mode 100644
index 00000000..f26da7af
--- /dev/null
+++ b/frontend/lib/admin/commands.ts
@@ -0,0 +1,178 @@
+import { adminRequest } from "./api";
+import type { LpNode } from "./luckperms";
+
+export type CommandGroup = {
+ name: string;
+ display_name: string | null;
+ weight: number | null;
+ members: number;
+ kind: "rank" | "staff" | "step";
+ track: string | null;
+ parents: string[];
+ nodes: LpNode[];
+};
+
+export type RegisteredCommand = {
+ name: string;
+ aliases: string[];
+ description: string | null;
+ usage: string | null;
+ permission: string | null;
+ shadowed: boolean;
+ /** One character per group, in the response's group order. */
+ access: string | null;
+};
+
+export type CommandPlugin = { name: string; version: string | null; commands: RegisteredCommand[] };
+export type CommandsSnapshot = {
+ status: { server: string; generated_at: number; received_at: string; fresh: boolean } | null;
+ groups: CommandGroup[];
+ plugins: CommandPlugin[];
+};
+export type CommandState = "y" | "n" | "o";
+export type CommandFilter = "all" | "player" | "staff" | "open";
+export type CommandsView = "matrix" | "roles";
+export type CommandsUrlState = {
+ view: CommandsView | null;
+ role: string;
+ q: string;
+ filter: CommandFilter;
+ plugin: string;
+ state: CommandState;
+ onlyNew: boolean;
+ steps: boolean;
+};
+
+export const GROUP_SECTIONS = [
+ { kind: "rank", label: "Player ranks" },
+ { kind: "staff", label: "Staff ranks" },
+ { kind: "step", label: "Track steps" },
+] as const;
+
+export function getCommands(): Promise {
+ return adminRequest("/admin/luckperms/commands");
+}
+
+export function groupLabel(group: CommandGroup): string {
+ return group.display_name ?? group.name;
+}
+
+export function commandLabel(command: RegisteredCommand, plugin: CommandPlugin): string {
+ return `/${command.shadowed ? `${plugin.name.toLowerCase()}:` : ""}${command.name}`;
+}
+
+export function commandState(command: RegisteredCommand, groupIndex: number): CommandState {
+ return command.access === null ? "o" : command.access[groupIndex] === "y" ? "y" : "n";
+}
+
+export function commandCounts(commands: RegisteredCommand[], groupIndex: number): Record {
+ const counts = { y: 0, n: 0, o: 0 };
+ for (const command of commands) counts[commandState(command, groupIndex)]++;
+ return counts;
+}
+
+export function matchesCommand(command: RegisteredCommand, plugin: CommandPlugin, query: string): boolean {
+ const text = query.trim().toLowerCase().replace(/^\//, "");
+ return (
+ !text ||
+ [
+ command.name,
+ commandLabel(command, plugin),
+ ...command.aliases,
+ command.permission,
+ command.description,
+ command.usage,
+ plugin.name,
+ ].some((value) => value?.toLowerCase().includes(text))
+ );
+}
+
+export function matchesFilter(command: RegisteredCommand, groups: CommandGroup[], filter: CommandFilter): boolean {
+ if (filter === "open") return command.access === null;
+ if (filter === "player") {
+ const index = groups.findIndex((group) => group.kind === "rank");
+ return index >= 0 && commandState(command, index) === "y";
+ }
+ if (filter === "staff") {
+ return (
+ groups.every((group, index) => group.kind !== "rank" || commandState(command, index) === "n") &&
+ groups.some((group, index) => group.kind === "staff" && commandState(command, index) === "y")
+ );
+ }
+ return true;
+}
+
+export function filterPlugins(
+ plugins: CommandPlugin[],
+ predicate: (command: RegisteredCommand, plugin: CommandPlugin) => boolean,
+): CommandPlugin[] {
+ return plugins
+ .map((plugin) => ({ ...plugin, commands: plugin.commands.filter((command) => predicate(command, plugin)) }))
+ .filter((plugin) => plugin.commands.length > 0);
+}
+
+export function totalCommands(plugins: CommandPlugin[]): number {
+ return plugins.reduce((total, plugin) => total + plugin.commands.length, 0);
+}
+
+/** Compare with the preceding group in this section, independently of its inheritance. */
+export function previousGroup(groups: CommandGroup[], group: CommandGroup): CommandGroup | undefined {
+ const section = groups.filter((candidate) => candidate.kind === group.kind);
+ return section[section.findIndex((candidate) => candidate.name === group.name) - 1];
+}
+
+export function isNewCommand(command: RegisteredCommand, groupIndex: number, previousIndex: number): boolean {
+ return (
+ previousIndex >= 0 && commandState(command, groupIndex) === "y" && commandState(command, previousIndex) !== "y"
+ );
+}
+
+/** Direct parents first, then their parents. Shared ancestors and cycles appear only once. */
+export function inheritanceChain(group: CommandGroup, groups: CommandGroup[]): string[] {
+ const byName = new Map(groups.map((item) => [item.name, item]));
+ const seen = new Set([group.name]);
+ const pending = [...group.parents];
+ const chain: string[] = [];
+ for (let i = 0; i < pending.length; i++) {
+ const name = pending[i];
+ if (seen.has(name)) continue;
+ seen.add(name);
+ chain.push(name);
+ pending.push(...(byName.get(name)?.parents ?? []));
+ }
+ return chain;
+}
+
+export function parseCommandsState(params: URLSearchParams): CommandsUrlState {
+ const view = params.get("view");
+ const filter = params.get("filter");
+ const state = params.get("state");
+ return {
+ view: view === "roles" || view === "matrix" ? view : null,
+ role: params.get("role") ?? "",
+ q: params.get("q") ?? "",
+ filter: filter === "player" || filter === "staff" || filter === "open" ? filter : "all",
+ plugin: params.get("plugin") ?? "",
+ state: state === "n" || state === "o" ? state : "y",
+ onlyNew: params.get("new") === "1",
+ steps: params.get("steps") === "1",
+ };
+}
+
+export function commandsUrl(url: URL, state: CommandsUrlState): string {
+ const values = {
+ view: state.view,
+ role: state.role,
+ q: state.q.trim(),
+ filter: state.filter === "all" ? "" : state.filter,
+ plugin: state.plugin,
+ state: state.state === "y" ? "" : state.state,
+ new: state.onlyNew ? "1" : "",
+ steps: state.steps ? "1" : "",
+ };
+ for (const [key, value] of Object.entries(values)) {
+ if (value) url.searchParams.set(key, value);
+ else url.searchParams.delete(key);
+ }
+ return `${url.pathname}${url.search}${url.hash}`;
+}
From fdafbcacafc39d59c9c95b2d813ca3767963202f Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Thu, 15 Oct 2026 15:35:26 +0000
Subject: [PATCH 3/4] Accept command permissions up to 2048 characters
Co-Authored-By: Claude Opus 5.5
---
backend/src/luckperms/commands.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/src/luckperms/commands.py b/backend/src/luckperms/commands.py
index 1185a4a5..d41c0fd2 100644
--- a/backend/src/luckperms/commands.py
+++ b/backend/src/luckperms/commands.py
@@ -19,7 +19,7 @@
ALIASES_MAX = 64
NAME_MAX = 64
TEXT_MAX = 300
-PERMISSION_MAX = 512
+PERMISSION_MAX = 2048
# TFMCWeb resends at least every ten minutes.
FRESH_SECONDS = 30 * 60
KIND_ORDER = {"rank": 0, "staff": 1, "step": 2}
From 3013a62eb3f7689be4636a4385f9d5d4bcb4deee Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Thu, 15 Oct 2026 15:44:35 +0000
Subject: [PATCH 4/4] Mark the command list stale by age and qualify open
commands
The stale warning now follows the time since the list arrived while the
page stays open. Commands without a Bukkit permission no longer claim
everyone sees them.
Co-Authored-By: Claude Opus 5.5
---
.../admin/commands/CommandsOverview.test.tsx | 18 +++++++++++++++++-
.../admin/commands/CommandsOverview.tsx | 8 ++++++--
.../app/components/admin/commands/parts.tsx | 4 ++--
3 files changed, 25 insertions(+), 5 deletions(-)
diff --git a/frontend/app/components/admin/commands/CommandsOverview.test.tsx b/frontend/app/components/admin/commands/CommandsOverview.test.tsx
index e6fcfbb4..0ea85446 100644
--- a/frontend/app/components/admin/commands/CommandsOverview.test.tsx
+++ b/frontend/app/components/admin/commands/CommandsOverview.test.tsx
@@ -1,5 +1,5 @@
/** @vitest-environment jsdom */
-import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { AccountApiError } from "@/lib/account/api";
import {
@@ -222,6 +222,22 @@ it("shows missing and stale command lists", async () => {
expect(await screen.findByText("Command list is stale · published 2 h ago.")).toBeTruthy();
});
+it("marks the list stale once 30 minutes pass without a new one", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ vi.mocked(getCommands).mockResolvedValue(snapshot());
+ render( );
+ await screen.findByRole("searchbox");
+ expect(screen.queryByText(/Command list is stale/)).toBeNull();
+ await act(async () => {
+ vi.advanceTimersByTime(31 * 60_000);
+ });
+ expect(screen.getByText(/Command list is stale/)).toBeTruthy();
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
it.each([
[401, "Sign in with Discord"],
[403, "This page is for TFMC staff only."],
diff --git a/frontend/app/components/admin/commands/CommandsOverview.tsx b/frontend/app/components/admin/commands/CommandsOverview.tsx
index d4370210..5b2f956d 100644
--- a/frontend/app/components/admin/commands/CommandsOverview.tsx
+++ b/frontend/app/components/admin/commands/CommandsOverview.tsx
@@ -28,6 +28,8 @@ function subscribePhone(onChange: () => void) {
return () => media?.removeEventListener?.("change", onChange);
}
+const STALE_SECONDS = 30 * 60;
+
export default function CommandsOverview({ initialState = defaultState() }: { initialState?: CommandsUrlState }) {
const [load, setLoad] = useState({ kind: "loading" });
const [state, setState] = useState(initialState);
@@ -75,6 +77,8 @@ export default function CommandsOverview({ initialState = defaultState() }: { in
? `${Math.floor((now - status.generated_at) / 86400)} d ago`
: formatAgo(status.generated_at, now).toLowerCase()
: "";
+ // The server resends at least every ten minutes; 30 minutes without one means the list may be out of date.
+ const stale = status ? !status.fresh || now - Date.parse(status.received_at) / 1000 > STALE_SECONDS : false;
const noPermission = data.plugins
.flatMap((plugin) => plugin.commands)
.filter((command) => command.access === null).length;
@@ -106,7 +110,7 @@ export default function CommandsOverview({ initialState = defaultState() }: { in
) : (
<>
- {!status.fresh ? (
+ {stale ? (
Command list is stale · published {age}.
@@ -192,7 +196,7 @@ export default function CommandsOverview({ initialState = defaultState() }: { in
{noPermission} commands have no permission set.
- Visible to everyone; the plugin decides when run.
+ Players are offered them unless the plugin hides them itself.