Unverified Commit 21dd4bae authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): LuckPerms 页读不到时如实说读不到,用户组和权限节点可按名称移除

parent d0ab983f
Loading
Loading
Loading
Loading
+7 −1
Changes for panel/src/i18n/resources/en-US/servers.json: 7 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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",
+7 −1
Changes for panel/src/i18n/resources/zh-CN/servers.json: 7 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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": "尚未分配任何权限节点",
+75 −0
Changes for panel/src/pages/ServerLuckPerms.test.tsx: 75 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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();
  });
});
+84 −14
Changes for panel/src/pages/ServerLuckPerms.tsx: 84 added lines, 14 removed lines.
Original line number Diff line number Diff line
@@ -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<boolean> => {
    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<boolean> => {
    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() {
                                </button>
                              </Badge>
                            ))
                          ) : lpUnread ? (
                            <p className="text-xs text-muted-foreground py-1">{t("luckperms_groups_unread")}</p>
                          ) : (
                            <p className="text-xs text-muted-foreground/60 italic py-1">{t("luckperms_no_parent_groups")}</p>
                          )}
@@ -538,6 +570,7 @@ export function ServerLuckPerms() {
                              </Select>
                            </div>
                          </div>
                          <div className="flex gap-2 w-full sm:w-auto">
                            <Button
                              size="sm"
                              onClick={() => {
@@ -548,11 +581,27 @@ export function ServerLuckPerms() {
                                }
                              }}
                              disabled={submitting || !groupNameInput.trim()}
                            className="h-9 px-4 shrink-0 w-full sm:w-auto hover:bg-primary/90 transition-colors"
                              className="h-9 px-4 shrink-0 flex-1 sm:flex-none hover:bg-primary/90 transition-colors"
                            >
                              <Plus className="h-4 w-4 mr-1" />
                              {t("luckperms_add_group_btn")}
                            </Button>
                            <Button
                              size="sm"
                              variant="outline"
                              onClick={async () => {
                                const val = groupNameInput.trim();
                                if (val && (await handleRemoveGroup(val))) {
                                  setGroupNameInput("");
                                }
                              }}
                              disabled={submitting || !groupNameInput.trim()}
                              className="h-9 px-4 shrink-0 flex-1 sm:flex-none hover:text-destructive transition-colors"
                            >
                              <Trash2 className="h-4 w-4 mr-1" />
                              {t("luckperms_remove_group_btn")}
                            </Button>
                          </div>
                        </div>
                      </div>
                    )}
@@ -634,9 +683,15 @@ export function ServerLuckPerms() {
                                  ))
                                ) : (
                                  <tr>
                                    {lpUnread ? (
                                      <td colSpan={4} className="px-4 py-10 text-center text-muted-foreground">
                                        {t("luckperms_perms_unread")}
                                      </td>
                                    ) : (
                                      <td colSpan={4} className="px-4 py-10 text-center text-muted-foreground/60 italic">
                                        {t("luckperms_no_perms")}
                                      </td>
                                    )}
                                  </tr>
                                )}
                              </tbody>
@@ -647,7 +702,7 @@ export function ServerLuckPerms() {
                        {/* Add Permission Node Inline Form */}
                        <form onSubmit={handleAddPermission} className="border-t border-border/50 pt-4 space-y-4">
                          <Label className="text-xs font-semibold text-muted-foreground">
                            {t("luckperms_add_perm_btn")}
                            {t("luckperms_perm_form_title")}
                          </Label>
                          <div className="grid grid-cols-1 sm:grid-cols-4 gap-3.5">
                            <div className="sm:col-span-2 grid gap-1.5">
@@ -716,15 +771,30 @@ export function ServerLuckPerms() {
                            </div>
                          </div>

                          <div className="flex justify-end pt-1">
                          <div className="flex flex-col sm:flex-row sm:items-center gap-3 pt-1">
                            <p className="text-[11px] text-muted-foreground sm:flex-1">
                              {t("luckperms_remove_perm_hint")}
                            </p>
                            <div className="flex gap-2">
                              <Button
                                type="submit"
                                disabled={submitting || !permNodeInput.trim()}
                              className="w-full sm:w-auto h-9 hover:bg-primary/90 transition-colors"
                                className="flex-1 sm:flex-none h-9 hover:bg-primary/90 transition-colors"
                              >
                                <Plus className="h-4 w-4 mr-1" />
                                {t("luckperms_add_perm_btn")}
                              </Button>
                              <Button
                                type="button"
                                variant="outline"
                                onClick={handleRemoveTypedPermission}
                                disabled={submitting || !permNodeInput.trim()}
                                className="flex-1 sm:flex-none h-9 hover:text-destructive transition-colors"
                              >
                                <Trash2 className="h-4 w-4 mr-1" />
                                {t("luckperms_remove_perm_btn")}
                              </Button>
                            </div>
                          </div>
                        </form>
                      </div>