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..d41c0fd2 --- /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 = 2048 +# 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, 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) => ( + + ))} +
+ +
+
+
+

+ {count}{" "} + {filtered ? `of ${totalCommands(data.plugins)} ` : ""}commands · {list.length}{" "} + {list.length === 1 ? "plugin" : "plugins"} +

+
+ + {list.length > 0 ? ( + + ) : null} +
+
+ {!list.length ? ( + + ) : ( + <> +
+ Command column stays fixed + Scroll ranks → +
+
+ + + + + {columns.map(({ group }) => ( + + ))} + + + + + {sections.map((section, index) => ( + + ))} + + + {columns.map(({ group }, index) => ( + + ))} + + + + {list.map((plugin) => ( + + + + {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 ( + + ); + })} + + {isOpen(plugin.name) + ? plugin.commands.map((command) => { + const id = `${plugin.name}/${command.name}`; + const open = openRow === id; + return ( + + + + {columns.map(({ group, index }, column) => ( + + ))} + + {open ? ( + + + + ) : null} + + ); + }) + : null} + + ))} + +
+ Commands grouped by plugin, with command permission results for each role. +
+ Plugin / command + + {section.label} +
+ {groupLabel(group)} + + {group.members} {group.members === 1 ? "member" : "members"} + +
+ + + {uniform ? ( + + ) : ( + + {counts.y} + + {" "} + / {plugin.commands.length} + + + )} +
+ + + +
+
+ +
+
+
+ + )} +
+ {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 ( +
+ +
+ +
+

+ + + {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) => ( + + ))} +
+
+

+ {state.q || state.onlyNew + ? `${totalCommands(list)} commands` + : state.state === "y" + ? "Allowed commands" + : state.state === "n" + ? "Hidden commands" + : "No permission set"} +

+ {state.state === "y" && previous ? ( + + ) : ( + {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 ( + +
+ + + {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..0ea85446 --- /dev/null +++ b/frontend/app/components/admin/commands/CommandsOverview.test.tsx @@ -0,0 +1,251 @@ +/** @vitest-environment jsdom */ +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 { + 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("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."], + [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..5b2f956d --- /dev/null +++ b/frontend/app/components/admin/commands/CommandsOverview.tsx @@ -0,0 +1,226 @@ +"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); +} + +const STALE_SECONDS = 30 * 60; + +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() + : ""; + // 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; + + return ( +
+
+

Commands

+ {status ? ( +

+ + {totalCommands(data.plugins)} commands · {data.plugins.length} plugins + + + · {status.server} · Published{" "} + + +

+ ) : null} +
+ {!status ? ( +

+ No command list yet. TFMCWeb publishes it when the LuckPerms bridge is on. +

+ ) : ( + <> + {stale ? ( +

+ Command list is stale · published {age}. +

+ ) : null} +
+
+
+ {(["matrix", "roles"] as const).map((value) => ( + + ))} +
+ {data.groups.some((group) => group.kind === "step") ? ( + + ) : null} +
+
+ + + 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 ? ( + + ) : null} +
+
+ {noPermission > 0 ? ( +
+ +

+ {noPermission} commands have no permission set. + Players are offered them unless the plugin hides them itself. +

+ +
+ ) : 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..a0acf01d --- /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 ( + + ); +} + +export function Caret({ open = false }: { open?: boolean }) { + return ( + + ); +} + +export function StateMark({ state }: { state: CommandState }) { + return ( + + + + ); +} + +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.

+ +
+ ); +} + +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], + ] + : []), + [ + "Allowed for", + command.access === null + ? "Up to the plugin" + : 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}`; +}