diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 663598a..2c483a6 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -157,6 +157,12 @@ "luckperms_select_player_prompt": "Please select an online player from the list, or enter a username to query.", "luckperms_add_group_btn": "Add Parent Group", "luckperms_add_perm_btn": "Add Permission", + "luckperms_remove_group_btn": "Remove Parent Group", + "luckperms_remove_perm_btn": "Remove Permission", + "luckperms_perm_form_title": "Add or remove a permission", + "luckperms_remove_perm_hint": "Remove clears the node in the given world context whether it was granted or denied; Value applies to Add only.", + "luckperms_groups_unread": "Couldn't read this player's groups. To remove one, type its name below and press Remove Parent Group.", + "luckperms_perms_unread": "Couldn't read this player's permission nodes. To remove one, type the node (and world, if it was set per world) below and press Remove Permission.", "luckperms_groups_title": "Parent Groups", "luckperms_permissions_title": "Permission Nodes", "luckperms_node_column": "Permission Node", @@ -175,7 +181,7 @@ "luckperms_revert_success": "Action reverted successfully!", "luckperms_presets": "Presets", "luckperms_no_parent_groups": "No parent groups assigned", - "luckperms_no_reply": "The server returned no parseable data — some LuckPerms versions do not reply over RCON, so the group/permission lists below may be incomplete. Grants and revocations are still sent to the server; name-based writes only bind for players who have joined since LuckPerms was installed, so writes against names that never joined may silently do nothing.", + "luckperms_no_reply": "LuckPerms doesn't answer over RCON on this server, so the player's current groups and permission nodes can't be shown. Adding and removing still go through: type the group or node name below. Name-based changes only bind for players who have joined since LuckPerms was installed, so changes against a name that never joined may silently do nothing.", "luckperms_no_output": "(the server returned no output — result unknown)", "luckperms_global": "global", "luckperms_no_perms": "No explicit permission nodes assigned", diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 224b7ff..a7f25cc 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -157,6 +157,12 @@ "luckperms_select_player_prompt": "请在左侧选择在线玩家,或在上方输入玩家名进行查询", "luckperms_add_group_btn": "添加用户组", "luckperms_add_perm_btn": "添加权限", + "luckperms_remove_group_btn": "移除用户组", + "luckperms_remove_perm_btn": "移除权限", + "luckperms_perm_form_title": "添加或移除权限", + "luckperms_remove_perm_hint": "移除会清掉该世界范围里的这个节点,授予和拒绝都一样清掉;权限值只对添加生效。", + "luckperms_groups_unread": "读不到这名玩家现有的用户组。要移除某个组,在下面填上组名后点「移除用户组」。", + "luckperms_perms_unread": "读不到这名玩家现有的权限节点。要移除某个节点,在下面填上节点名(按世界设置的再填世界)后点「移除权限」。", "luckperms_groups_title": "所属用户组", "luckperms_permissions_title": "拥有权限节点", "luckperms_node_column": "权限节点", @@ -175,7 +181,7 @@ "luckperms_revert_success": "已成功撤销该操作!", "luckperms_presets": "预设", "luckperms_no_parent_groups": "未分配任何父组", - "luckperms_no_reply": "服务器没有返回可解析的数据——部分 LuckPerms 版本不通过 RCON 回包,下面的用户组/权限列表可能不准确。授予与撤销会照常发给服务器执行;但按玩家名的操作只对「自 LuckPerms 安装以来进过本服」的玩家可靠,对没进过服的玩家名可能静默不生效。", + "luckperms_no_reply": "这台服上的 LuckPerms 不通过 RCON 回话,所以显示不出玩家现有的用户组和权限节点。添加和移除照常生效:在下面填上组名或节点名即可。按玩家名的操作只对「自 LuckPerms 安装以来进过本服」的玩家可靠,对没进过服的玩家名可能静默不生效。", "luckperms_no_output": "(服务器未返回输出,无法确认结果)", "luckperms_global": "全局", "luckperms_no_perms": "尚未分配任何权限节点", diff --git a/panel/src/pages/ServerLuckPerms.test.tsx b/panel/src/pages/ServerLuckPerms.test.tsx index bf9eb43..527cfba 100644 --- a/panel/src/pages/ServerLuckPerms.test.tsx +++ b/panel/src/pages/ServerLuckPerms.test.tsx @@ -13,6 +13,7 @@ const calls = vi.hoisted(() => ({ accessPlayers: vi.fn(), accessLuckPermsInfo: vi.fn(), accessGroup: vi.fn(), + accessPermission: vi.fn(), })); vi.mock("@/lib/tier", () => ({ useTier: () => ({ @@ -37,6 +38,7 @@ beforeEach(() => { calls.accessPlayers.mockResolvedValue({ online: 0, max: 20, players: [], output: "" }); calls.accessLuckPermsInfo.mockRejectedValue(missing); calls.accessGroup.mockRejectedValue(missing); + calls.accessPermission.mockRejectedValue(missing); }); afterEach(() => { vi.restoreAllMocks(); @@ -106,3 +108,76 @@ describe("ServerLuckPerms on a server that is down", () => { expect(screen.queryByPlaceholderText("Steve")).toBeNull(); }); }); + +// LuckPerms 5.5 answers every `lp` command over RCON with an empty body, so the +// read of a player comes back with no output and no entries. The lists must not +// claim the player has nothing, and whatever was granted must still be removable +// by name, since no read row ever appears to hang a remove button on. +describe("ServerLuckPerms when LuckPerms does not answer", () => { + const silent = { player: "Alex", groups: [], permissions: [], output: "" }; + + beforeEach(() => { + calls.accessLuckPermsInfo.mockResolvedValue(silent); + calls.accessGroup.mockResolvedValue({ output: "" }); + calls.accessPermission.mockResolvedValue({ output: "" }); + }); + + async function lookUpAlex() { + renderPage(); + await userEvent.type(await screen.findByPlaceholderText("Steve"), "Alex{Enter}"); + await screen.findByText(/Couldn't read this player's groups/); + } + + it("says the lists are unread, never that they are empty", async () => { + await lookUpAlex(); + expect(screen.getByText(/Couldn't read this player's permission nodes/)).toBeTruthy(); + expect(screen.queryByText("No parent groups assigned")).toBeNull(); + expect(screen.queryByText("No explicit permission nodes assigned")).toBeNull(); + }); + + it("removes a typed group", async () => { + await lookUpAlex(); + const input = screen.getByLabelText("Group Name"); + await userEvent.type(input, "vip"); + await userEvent.click(screen.getByRole("button", { name: /remove parent group/i })); + expect(calls.accessGroup).toHaveBeenCalledWith("lobby", "remove", "Alex", "vip"); + expect(calls.accessGroup).toHaveBeenCalledTimes(1); + expect((input as HTMLInputElement).value).toBe(""); + }); + + it("removes a typed node in its world, whatever value it held", async () => { + await lookUpAlex(); + await userEvent.type(screen.getByLabelText("Permission Node"), "essentials.fly"); + await userEvent.type(screen.getByLabelText("World Context (Optional)"), "world_nether"); + await userEvent.click(screen.getByRole("button", { name: /^remove permission$/i })); + expect(calls.accessPermission).toHaveBeenCalledWith("lobby", "unset", "Alex", "essentials.fly", undefined, "world_nether"); + expect(calls.accessPermission).toHaveBeenCalledTimes(1); + expect((screen.getByLabelText("Permission Node") as HTMLInputElement).value).toBe(""); + }); + + it("refuses a malformed typed node without sending it", async () => { + await lookUpAlex(); + await userEvent.type(screen.getByLabelText("Permission Node"), "essentials fly"); + await userEvent.click(screen.getByRole("button", { name: /^remove permission$/i })); + expect((await screen.findByRole("alert")).textContent).toMatch(/Invalid permission node/); + expect(calls.accessPermission).not.toHaveBeenCalled(); + }); + + it("keeps the typed node when the removal is refused", async () => { + calls.accessPermission.mockRejectedValue(missing); + await lookUpAlex(); + await userEvent.type(screen.getByLabelText("Permission Node"), "essentials.fly"); + await userEvent.click(screen.getByRole("button", { name: /^remove permission$/i })); + expect((await screen.findByRole("alert")).textContent).toMatch(/LuckPerms isn't installed/); + expect((screen.getByLabelText("Permission Node") as HTMLInputElement).value).toBe("essentials.fly"); + }); + + it("still says none when LuckPerms did answer with nothing", async () => { + calls.accessLuckPermsInfo.mockResolvedValue({ ...silent, output: "Alex has no parent groups." }); + renderPage(); + await userEvent.type(await screen.findByPlaceholderText("Steve"), "Alex{Enter}"); + expect(await screen.findByText("No parent groups assigned")).toBeTruthy(); + expect(screen.getByText("No explicit permission nodes assigned")).toBeTruthy(); + expect(screen.queryByText(/Couldn't read this player's/)).toBeNull(); + }); +}); diff --git a/panel/src/pages/ServerLuckPerms.tsx b/panel/src/pages/ServerLuckPerms.tsx index 6bdff46..54f0c9a 100644 --- a/panel/src/pages/ServerLuckPerms.tsx +++ b/panel/src/pages/ServerLuckPerms.tsx @@ -104,6 +104,10 @@ export function ServerLuckPerms() { !(lpInfo.output ?? "").trim() && (lpInfo.groups?.length ?? 0) === 0 && (lpInfo.permissions?.length ?? 0) === 0; + // Unread: the lists below know nothing, so they say so instead of "none", and a + // group or node is removed by typing it — a read row is the only other way in, + // and on LuckPerms 5.5 there never is one. + const lpUnread = lpSilent || !!lpError; // Form State const [groupNameInput, setGroupNameInput] = useState(""); @@ -162,8 +166,9 @@ export function ServerLuckPerms() { } }; - const handleRemoveGroup = async (groupName: string) => { - if (!selectedPlayer || submitting) return; + // Returns whether the server took the removal, so the typed form can clear itself. + const handleRemoveGroup = async (groupName: string): Promise => { + if (!selectedPlayer || submitting) return false; setSubmitting(true); setFormFeedback(null); try { @@ -177,27 +182,37 @@ export function ServerLuckPerms() { output: res.output || t("luckperms_no_output"), }); reloadLp(); + return true; } catch (err) { setFormFeedback({ kind: "err", msg: humanizeError(err) }); + return false; } finally { setSubmitting(false); } }; - const handleAddPermission = async (e: React.FormEvent) => { - e.preventDefault(); - if (!selectedPlayer || !permNodeInput.trim() || submitting) return; + // typedPermission is the node and world the form holds, or null (with the reason + // shown) when either is malformed. + const typedPermission = (): { node: string; world: string } | null => { const node = permNodeInput.trim(); const world = permWorldInput.trim(); - if (!LP_NODE.test(node)) { setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_node") }); - return; + return null; } if (world !== "" && !LP_CTX.test(world)) { setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_world") }); - return; + return null; } + return { node, world }; + }; + + const handleAddPermission = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedPlayer || !permNodeInput.trim() || submitting) return; + const typed = typedPermission(); + if (!typed) return; + const { node, world } = typed; setSubmitting(true); setFormFeedback(null); @@ -223,8 +238,9 @@ export function ServerLuckPerms() { } }; - const handleRemovePermission = async (node: string, world?: string) => { - if (!selectedPlayer || submitting) return; + // Returns whether the server took the unset, so the typed form can clear itself. + const handleRemovePermission = async (node: string, world?: string): Promise => { + if (!selectedPlayer || submitting) return false; setSubmitting(true); setFormFeedback(null); try { @@ -239,13 +255,27 @@ export function ServerLuckPerms() { output: res.output || t("luckperms_no_output"), }); reloadLp(); + return true; } catch (err) { setFormFeedback({ kind: "err", msg: humanizeError(err) }); + return false; } finally { setSubmitting(false); } }; + // The typed removal: the node and world as the form holds them, whatever value + // the node was set to (LuckPerms unsets a node in a context regardless of it). + const handleRemoveTypedPermission = async () => { + if (!selectedPlayer || !permNodeInput.trim() || submitting) return; + const typed = typedPermission(); + if (!typed) return; + if (await handleRemovePermission(typed.node, typed.world)) { + setPermNodeInput(""); + setPermWorldInput(""); + } + }; + const handleRevert = async (item: ActionHistoryItem) => { if (submitting || item.reverted) return; setSubmitting(true); @@ -503,6 +533,8 @@ export function ServerLuckPerms() { )) + ) : lpUnread ? ( +

{t("luckperms_groups_unread")}

) : (

{t("luckperms_no_parent_groups")}

)} @@ -538,21 +570,38 @@ export function ServerLuckPerms() { - +
+ + +
)} @@ -634,9 +683,15 @@ export function ServerLuckPerms() { )) ) : ( - - {t("luckperms_no_perms")} - + {lpUnread ? ( + + {t("luckperms_perms_unread")} + + ) : ( + + {t("luckperms_no_perms")} + + )} )} @@ -647,7 +702,7 @@ export function ServerLuckPerms() { {/* Add Permission Node Inline Form */}
@@ -716,15 +771,30 @@ export function ServerLuckPerms() {
-
- +
+

+ {t("luckperms_remove_perm_hint")} +

+
+ + +