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

This commit is contained in:
Lemon-miaow committed 2026-09-27 00:44:01 +08:00
1 parent d0ab983f0f
commit 21dd4baed2
4 files changed
+197 -40

No files matched your search

+7 -1
View File
@@ -157,6 +157,12 @@
"luckperms_select_player_prompt": "Please select an online player from the list, or enter a username to query.", "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_group_btn": "Add Parent Group",
"luckperms_add_perm_btn": "Add Permission", "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_groups_title": "Parent Groups",
"luckperms_permissions_title": "Permission Nodes", "luckperms_permissions_title": "Permission Nodes",
"luckperms_node_column": "Permission Node", "luckperms_node_column": "Permission Node",
@@ -175,7 +181,7 @@
"luckperms_revert_success": "Action reverted successfully!", "luckperms_revert_success": "Action reverted successfully!",
"luckperms_presets": "Presets", "luckperms_presets": "Presets",
"luckperms_no_parent_groups": "No parent groups assigned", "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_no_output": "(the server returned no output — result unknown)",
"luckperms_global": "global", "luckperms_global": "global",
"luckperms_no_perms": "No explicit permission nodes assigned", "luckperms_no_perms": "No explicit permission nodes assigned",
+7 -1
View File
@@ -157,6 +157,12 @@
"luckperms_select_player_prompt": "请在左侧选择在线玩家,或在上方输入玩家名进行查询", "luckperms_select_player_prompt": "请在左侧选择在线玩家,或在上方输入玩家名进行查询",
"luckperms_add_group_btn": "添加用户组", "luckperms_add_group_btn": "添加用户组",
"luckperms_add_perm_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_groups_title": "所属用户组",
"luckperms_permissions_title": "拥有权限节点", "luckperms_permissions_title": "拥有权限节点",
"luckperms_node_column": "权限节点", "luckperms_node_column": "权限节点",
@@ -175,7 +181,7 @@
"luckperms_revert_success": "已成功撤销该操作!", "luckperms_revert_success": "已成功撤销该操作!",
"luckperms_presets": "预设", "luckperms_presets": "预设",
"luckperms_no_parent_groups": "未分配任何父组", "luckperms_no_parent_groups": "未分配任何父组",
"luckperms_no_reply": "服务器没有返回可解析的数据——部分 LuckPerms 版本不通过 RCON 回包,下面的用户组/权限列表可能不准确。授予与撤销会照常发给服务器执行;但按玩家名的操作只对「自 LuckPerms 安装以来进过本服」的玩家可靠,对没进过服的玩家名可能静默不生效。", "luckperms_no_reply": "这台服上的 LuckPerms 不通过 RCON 回话,所以显示不出玩家现有的用户组和权限节点。添加和移除照常生效:在下面填上组名或节点名即可。按玩家名的操作只对「自 LuckPerms 安装以来进过本服」的玩家可靠,对没进过服的玩家名可能静默不生效。",
"luckperms_no_output": "(服务器未返回输出,无法确认结果)", "luckperms_no_output": "(服务器未返回输出,无法确认结果)",
"luckperms_global": "全局", "luckperms_global": "全局",
"luckperms_no_perms": "尚未分配任何权限节点", "luckperms_no_perms": "尚未分配任何权限节点",
+75
View File
@@ -13,6 +13,7 @@ const calls = vi.hoisted(() => ({
accessPlayers: vi.fn(), accessPlayers: vi.fn(),
accessLuckPermsInfo: vi.fn(), accessLuckPermsInfo: vi.fn(),
accessGroup: vi.fn(), accessGroup: vi.fn(),
accessPermission: vi.fn(),
})); }));
vi.mock("@/lib/tier", () => ({ vi.mock("@/lib/tier", () => ({
useTier: () => ({ useTier: () => ({
@@ -37,6 +38,7 @@ beforeEach(() => {
calls.accessPlayers.mockResolvedValue({ online: 0, max: 20, players: [], output: "" }); calls.accessPlayers.mockResolvedValue({ online: 0, max: 20, players: [], output: "" });
calls.accessLuckPermsInfo.mockRejectedValue(missing); calls.accessLuckPermsInfo.mockRejectedValue(missing);
calls.accessGroup.mockRejectedValue(missing); calls.accessGroup.mockRejectedValue(missing);
calls.accessPermission.mockRejectedValue(missing);
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -106,3 +108,76 @@ describe("ServerLuckPerms on a server that is down", () => {
expect(screen.queryByPlaceholderText("Steve")).toBeNull(); 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();
});
});
+108 -38
View File
@@ -104,6 +104,10 @@ export function ServerLuckPerms() {
!(lpInfo.output ?? "").trim() && !(lpInfo.output ?? "").trim() &&
(lpInfo.groups?.length ?? 0) === 0 && (lpInfo.groups?.length ?? 0) === 0 &&
(lpInfo.permissions?.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 // Form State
const [groupNameInput, setGroupNameInput] = useState(""); const [groupNameInput, setGroupNameInput] = useState("");
@@ -162,8 +166,9 @@ export function ServerLuckPerms() {
} }
}; };
const handleRemoveGroup = async (groupName: string) => { // Returns whether the server took the removal, so the typed form can clear itself.
if (!selectedPlayer || submitting) return; const handleRemoveGroup = async (groupName: string): Promise<boolean> => {
if (!selectedPlayer || submitting) return false;
setSubmitting(true); setSubmitting(true);
setFormFeedback(null); setFormFeedback(null);
try { try {
@@ -177,27 +182,37 @@ export function ServerLuckPerms() {
output: res.output || t("luckperms_no_output"), output: res.output || t("luckperms_no_output"),
}); });
reloadLp(); reloadLp();
return true;
} catch (err) { } catch (err) {
setFormFeedback({ kind: "err", msg: humanizeError(err) }); setFormFeedback({ kind: "err", msg: humanizeError(err) });
return false;
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const handleAddPermission = async (e: React.FormEvent) => { // typedPermission is the node and world the form holds, or null (with the reason
e.preventDefault(); // shown) when either is malformed.
if (!selectedPlayer || !permNodeInput.trim() || submitting) return; const typedPermission = (): { node: string; world: string } | null => {
const node = permNodeInput.trim(); const node = permNodeInput.trim();
const world = permWorldInput.trim(); const world = permWorldInput.trim();
if (!LP_NODE.test(node)) { if (!LP_NODE.test(node)) {
setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_node") }); setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_node") });
return; return null;
} }
if (world !== "" && !LP_CTX.test(world)) { if (world !== "" && !LP_CTX.test(world)) {
setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_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); setSubmitting(true);
setFormFeedback(null); setFormFeedback(null);
@@ -223,8 +238,9 @@ export function ServerLuckPerms() {
} }
}; };
const handleRemovePermission = async (node: string, world?: string) => { // Returns whether the server took the unset, so the typed form can clear itself.
if (!selectedPlayer || submitting) return; const handleRemovePermission = async (node: string, world?: string): Promise<boolean> => {
if (!selectedPlayer || submitting) return false;
setSubmitting(true); setSubmitting(true);
setFormFeedback(null); setFormFeedback(null);
try { try {
@@ -239,13 +255,27 @@ export function ServerLuckPerms() {
output: res.output || t("luckperms_no_output"), output: res.output || t("luckperms_no_output"),
}); });
reloadLp(); reloadLp();
return true;
} catch (err) { } catch (err) {
setFormFeedback({ kind: "err", msg: humanizeError(err) }); setFormFeedback({ kind: "err", msg: humanizeError(err) });
return false;
} finally { } finally {
setSubmitting(false); 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) => { const handleRevert = async (item: ActionHistoryItem) => {
if (submitting || item.reverted) return; if (submitting || item.reverted) return;
setSubmitting(true); setSubmitting(true);
@@ -503,6 +533,8 @@ export function ServerLuckPerms() {
</button> </button>
</Badge> </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> <p className="text-xs text-muted-foreground/60 italic py-1">{t("luckperms_no_parent_groups")}</p>
)} )}
@@ -538,21 +570,38 @@ export function ServerLuckPerms() {
</Select> </Select>
</div> </div>
</div> </div>
<Button <div className="flex gap-2 w-full sm:w-auto">
size="sm" <Button
onClick={() => { size="sm"
const val = groupNameInput.trim(); onClick={() => {
if (val) { const val = groupNameInput.trim();
handleAddGroup(val); if (val) {
setGroupNameInput(""); handleAddGroup(val);
} setGroupNameInput("");
}} }
disabled={submitting || !groupNameInput.trim()} }}
className="h-9 px-4 shrink-0 w-full sm:w-auto hover:bg-primary/90 transition-colors" disabled={submitting || !groupNameInput.trim()}
> 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")} <Plus className="h-4 w-4 mr-1" />
</Button> {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>
</div> </div>
)} )}
@@ -634,9 +683,15 @@ export function ServerLuckPerms() {
)) ))
) : ( ) : (
<tr> <tr>
<td colSpan={4} className="px-4 py-10 text-center text-muted-foreground/60 italic"> {lpUnread ? (
{t("luckperms_no_perms")} <td colSpan={4} className="px-4 py-10 text-center text-muted-foreground">
</td> {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> </tr>
)} )}
</tbody> </tbody>
@@ -647,7 +702,7 @@ export function ServerLuckPerms() {
{/* Add Permission Node Inline Form */} {/* Add Permission Node Inline Form */}
<form onSubmit={handleAddPermission} className="border-t border-border/50 pt-4 space-y-4"> <form onSubmit={handleAddPermission} className="border-t border-border/50 pt-4 space-y-4">
<Label className="text-xs font-semibold text-muted-foreground"> <Label className="text-xs font-semibold text-muted-foreground">
{t("luckperms_add_perm_btn")} {t("luckperms_perm_form_title")}
</Label> </Label>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3.5"> <div className="grid grid-cols-1 sm:grid-cols-4 gap-3.5">
<div className="sm:col-span-2 grid gap-1.5"> <div className="sm:col-span-2 grid gap-1.5">
@@ -716,15 +771,30 @@ export function ServerLuckPerms() {
</div> </div>
</div> </div>
<div className="flex justify-end pt-1"> <div className="flex flex-col sm:flex-row sm:items-center gap-3 pt-1">
<Button <p className="text-[11px] text-muted-foreground sm:flex-1">
type="submit" {t("luckperms_remove_perm_hint")}
disabled={submitting || !permNodeInput.trim()} </p>
className="w-full sm:w-auto h-9 hover:bg-primary/90 transition-colors" <div className="flex gap-2">
> <Button
<Plus className="h-4 w-4 mr-1" /> type="submit"
{t("luckperms_add_perm_btn")} disabled={submitting || !permNodeInput.trim()}
</Button> 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> </div>
</form> </form>
</div> </div>