fix(panel): LuckPerms 页读不到时如实说读不到,用户组和权限节点可按名称移除
This commit is contained in:
4 files changed
+197
-40
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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": "尚未分配任何权限节点",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,21 +570,38 @@ export function ServerLuckPerms() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const val = groupNameInput.trim();
|
||||
if (val) {
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("luckperms_add_group_btn")}
|
||||
</Button>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const val = groupNameInput.trim();
|
||||
if (val) {
|
||||
handleAddGroup(val);
|
||||
setGroupNameInput("");
|
||||
}
|
||||
}}
|
||||
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")}
|
||||
</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>
|
||||
<td colSpan={4} className="px-4 py-10 text-center text-muted-foreground/60 italic">
|
||||
{t("luckperms_no_perms")}
|
||||
</td>
|
||||
{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">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || !permNodeInput.trim()}
|
||||
className="w-full sm:w-auto h-9 hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("luckperms_add_perm_btn")}
|
||||
</Button>
|
||||
<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="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>
|
||||
|
||||
Reference in new issue
Block a user