fix(panel): 撤销删除的权限节点时按原值恢复,原值没读到就不给撤销

This commit is contained in:
Lemon-miaow committed 2026-09-27 00:49:24 +08:00
1 parent 21dd4baed2
commit 3f04171424
4 files changed
+155 -8

No files matched your search

@@ -175,6 +175,9 @@
"luckperms_recent_actions": "Recent Actions History", "luckperms_recent_actions": "Recent Actions History",
"luckperms_no_recent_actions": "No recent actions.", "luckperms_no_recent_actions": "No recent actions.",
"luckperms_revert": "Revert", "luckperms_revert": "Revert",
"luckperms_revert_to_set": "Revert sets {{node}} back to {{value}}",
"luckperms_revert_to_unset": "Revert removes {{node}}",
"luckperms_revert_unknown": "Can't revert: value unknown",
"luckperms_remove_group": "Remove group {{group}}", "luckperms_remove_group": "Remove group {{group}}",
"luckperms_remove_perm": "Remove permission {{node}}", "luckperms_remove_perm": "Remove permission {{node}}",
"luckperms_reverting": "Reverting...", "luckperms_reverting": "Reverting...",
@@ -175,6 +175,9 @@
"luckperms_recent_actions": "最近操作历史", "luckperms_recent_actions": "最近操作历史",
"luckperms_no_recent_actions": "暂无最近操作历史。", "luckperms_no_recent_actions": "暂无最近操作历史。",
"luckperms_revert": "撤销", "luckperms_revert": "撤销",
"luckperms_revert_to_set": "撤销会把 {{node}} 改回 {{value}}",
"luckperms_revert_to_unset": "撤销会移除 {{node}}",
"luckperms_revert_unknown": "无法撤销:原值未知",
"luckperms_remove_group": "移除权限组 {{group}}", "luckperms_remove_group": "移除权限组 {{group}}",
"luckperms_remove_perm": "移除权限 {{node}}", "luckperms_remove_perm": "移除权限 {{node}}",
"luckperms_reverting": "正在撤销...", "luckperms_reverting": "正在撤销...",
+105
View File
@@ -181,3 +181,108 @@ describe("ServerLuckPerms when LuckPerms does not answer", () => {
expect(screen.queryByText(/Couldn't read this player's/)).toBeNull(); expect(screen.queryByText(/Couldn't read this player's/)).toBeNull();
}); });
}); });
// A revert puts a permission back as it was. A removed deny comes back as a deny
// (it used to come back as a grant), a set that replaced a value restores that
// value, and a removal whose earlier value was never read offers no revert.
describe("ServerLuckPerms reverting a permission change", () => {
const read = {
player: "Alex",
groups: [],
permissions: [{ node: "essentials.fly", value: false, world: "world_nether" }],
output: "Alex's permissions: essentials.fly (false) world=world_nether",
};
beforeEach(() => {
calls.accessLuckPermsInfo.mockResolvedValue(read);
calls.accessPermission.mockResolvedValue({ output: "" });
});
async function lookUpAlex() {
renderPage();
await userEvent.type(await screen.findByPlaceholderText("Steve"), "Alex{Enter}");
}
async function typeNode(node: string, world = "") {
await userEvent.type(screen.getByLabelText("Permission Node"), node);
if (world) await userEvent.type(screen.getByLabelText("World Context (Optional)"), world);
}
it("puts a removed deny back as a deny", async () => {
await lookUpAlex();
await userEvent.click(await screen.findByRole("button", { name: "Remove permission essentials.fly" }));
expect(calls.accessPermission).toHaveBeenLastCalledWith("lobby", "unset", "Alex", "essentials.fly", undefined, "world_nether");
expect(await screen.findByText("- essentials.fly (FALSE) [world_nether]")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "Revert" }));
expect(calls.accessPermission).toHaveBeenLastCalledWith("lobby", "set", "Alex", "essentials.fly", false, "world_nether");
});
it("puts back the value a typed removal took, matched without case", async () => {
await lookUpAlex();
await screen.findByRole("button", { name: "Remove permission essentials.fly" });
await typeNode("Essentials.Fly", "World_Nether");
await userEvent.click(screen.getByRole("button", { name: /^remove permission$/i }));
await userEvent.click(await screen.findByRole("button", { name: "Revert" }));
expect(calls.accessPermission).toHaveBeenLastCalledWith("lobby", "set", "Alex", "Essentials.Fly", false, "World_Nether");
});
it("restores the value a set replaced", async () => {
await lookUpAlex();
await screen.findByRole("button", { name: "Remove permission essentials.fly" });
await typeNode("essentials.fly", "world_nether");
await userEvent.click(screen.getByRole("button", { name: /^add permission$/i }));
expect(calls.accessPermission).toHaveBeenLastCalledWith("lobby", "set", "Alex", "essentials.fly", true, "world_nether");
await userEvent.click(await screen.findByRole("button", { name: "Revert" }));
expect(calls.accessPermission).toHaveBeenLastCalledWith("lobby", "set", "Alex", "essentials.fly", false, "world_nether");
});
it("removes a set node that held nothing in that world", async () => {
await lookUpAlex();
await screen.findByRole("button", { name: "Remove permission essentials.fly" });
await typeNode("essentials.fly");
await userEvent.click(screen.getByRole("button", { name: /^add permission$/i }));
await userEvent.click(await screen.findByRole("button", { name: "Revert" }));
expect(calls.accessPermission).toHaveBeenLastCalledWith("lobby", "unset", "Alex", "essentials.fly", undefined, undefined);
});
it("offers no revert for a removal whose value was never read", async () => {
calls.accessLuckPermsInfo.mockResolvedValue({ player: "Alex", groups: [], permissions: [], output: "" });
await lookUpAlex();
await screen.findByText(/Couldn't read this player's permission nodes/);
await typeNode("essentials.fly", "world_nether");
await userEvent.click(screen.getByRole("button", { name: /^remove permission$/i }));
expect(await screen.findByText("Can't revert: value unknown")).toBeTruthy();
expect(screen.getByText("- essentials.fly [world_nether]")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Revert" })).toBeNull();
await typeNode("essentials.home");
await userEvent.click(screen.getByRole("button", { name: /^add permission$/i }));
await userEvent.click(await screen.findByRole("button", { name: "Revert" }));
expect(calls.accessPermission).toHaveBeenLastCalledWith("lobby", "unset", "Alex", "essentials.home", undefined, undefined);
});
// The read predates the set that just landed: it still shows the deny, but the
// node now holds a grant, so the removal after it must not offer to restore the
// deny.
async function setThenRemoveOnStaleRead() {
await lookUpAlex();
await screen.findByRole("button", { name: "Remove permission essentials.fly" });
await typeNode("essentials.fly", "world_nether");
await userEvent.click(screen.getByRole("button", { name: /^add permission$/i }));
await typeNode("essentials.fly", "world_nether");
await userEvent.click(screen.getByRole("button", { name: /^remove permission$/i }));
expect(calls.accessPermission).toHaveBeenLastCalledWith("lobby", "unset", "Alex", "essentials.fly", undefined, "world_nether");
expect(await screen.findByText("Can't revert: value unknown")).toBeTruthy();
expect(screen.getByText("- essentials.fly [world_nether]")).toBeTruthy();
}
it("trusts no read whose refresh failed", async () => {
calls.accessLuckPermsInfo.mockResolvedValueOnce(read).mockRejectedValue({ status: 502, code: "rcon_unavailable", message: "console down" });
await setThenRemoveOnStaleRead();
});
it("trusts no read still being refreshed", async () => {
calls.accessLuckPermsInfo.mockResolvedValueOnce(read).mockReturnValue(new Promise(() => {}));
await setThenRemoveOnStaleRead();
});
});
+44 -8
View File
@@ -48,6 +48,10 @@ interface ActionHistoryItem {
target: string; target: string;
value?: boolean; value?: boolean;
world?: string; world?: string;
// undo is the exact write that puts a permission back as it was before; absent
// when that state is unknown, so the entry offers no revert (a guess would turn
// a removed deny into a grant). Group entries invert their action instead.
undo?: { action: "set" | "unset"; value?: boolean };
status: "success" | "error"; status: "success" | "error";
output: string; output: string;
reverted?: boolean; reverted?: boolean;
@@ -207,12 +211,26 @@ export function ServerLuckPerms() {
return { node, world }; return { node, world };
}; };
// priorValue is the value the last read showed for a node in a world (LuckPerms
// matches both case-insensitively), or undefined when no row showed one: the
// node was unset, or nothing could be read. A read still in flight or one whose
// refresh failed is stale (it predates the last write), so it knows nothing.
const priorValue = (node: string, world: string): boolean | undefined => {
if (lpUnread || lpLoading) return undefined;
return lpInfo?.permissions?.find(
(p) => p.node.toLowerCase() === node.toLowerCase() && (p.world ?? "").toLowerCase() === world.toLowerCase(),
)?.value;
};
const handleAddPermission = async (e: React.FormEvent) => { const handleAddPermission = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedPlayer || !permNodeInput.trim() || submitting) return; if (!selectedPlayer || !permNodeInput.trim() || submitting) return;
const typed = typedPermission(); const typed = typedPermission();
if (!typed) return; if (!typed) return;
const { node, world } = typed; const { node, world } = typed;
// Reverting a set restores the value the node had; with none read, it removes
// the node, which undoes the set whenever the node was not there before.
const prior = priorValue(node, world);
setSubmitting(true); setSubmitting(true);
setFormFeedback(null); setFormFeedback(null);
@@ -225,6 +243,7 @@ export function ServerLuckPerms() {
target: node, target: node,
value: permValueInput, value: permValueInput,
world: world || undefined, world: world || undefined,
undo: prior === undefined ? { action: "unset" } : { action: "set", value: prior },
status: "success", status: "success",
output: res.output || t("luckperms_no_output"), output: res.output || t("luckperms_no_output"),
}); });
@@ -239,7 +258,8 @@ export function ServerLuckPerms() {
}; };
// Returns whether the server took the unset, so the typed form can clear itself. // Returns whether the server took the unset, so the typed form can clear itself.
const handleRemovePermission = async (node: string, world?: string): Promise<boolean> => { // value is what the node held, when known; only then can the removal be reverted.
const handleRemovePermission = async (node: string, world?: string, value?: boolean): Promise<boolean> => {
if (!selectedPlayer || submitting) return false; if (!selectedPlayer || submitting) return false;
setSubmitting(true); setSubmitting(true);
setFormFeedback(null); setFormFeedback(null);
@@ -250,7 +270,9 @@ export function ServerLuckPerms() {
player: selectedPlayer, player: selectedPlayer,
action: "unset", action: "unset",
target: node, target: node,
value,
world: world || undefined, world: world || undefined,
undo: value === undefined ? undefined : { action: "set", value },
status: "success", status: "success",
output: res.output || t("luckperms_no_output"), output: res.output || t("luckperms_no_output"),
}); });
@@ -270,7 +292,7 @@ export function ServerLuckPerms() {
if (!selectedPlayer || !permNodeInput.trim() || submitting) return; if (!selectedPlayer || !permNodeInput.trim() || submitting) return;
const typed = typedPermission(); const typed = typedPermission();
if (!typed) return; if (!typed) return;
if (await handleRemovePermission(typed.node, typed.world)) { if (await handleRemovePermission(typed.node, typed.world, priorValue(typed.node, typed.world))) {
setPermNodeInput(""); setPermNodeInput("");
setPermWorldInput(""); setPermWorldInput("");
} }
@@ -286,13 +308,14 @@ export function ServerLuckPerms() {
const inverseAction = item.action === "add" ? "remove" : "add"; const inverseAction = item.action === "add" ? "remove" : "add";
await api.accessGroup(name, inverseAction, item.player, item.target); await api.accessGroup(name, inverseAction, item.player, item.target);
} else { } else {
const inverseAction = item.action === "set" ? "unset" : "set"; const undo = item.undo;
if (!undo) return;
await api.accessPermission( await api.accessPermission(
name, name,
inverseAction, undo.action,
item.player, item.player,
item.target, item.target,
item.action === "set" ? item.value : undefined, undo.action === "set" ? undo.value : undefined,
item.world || undefined item.world || undefined
); );
} }
@@ -671,7 +694,7 @@ export function ServerLuckPerms() {
variant="ghost" variant="ghost"
size="sm" size="sm"
disabled={submitting} disabled={submitting}
onClick={() => handleRemovePermission(p.node, p.world)} onClick={() => handleRemovePermission(p.node, p.world, p.value)}
aria-label={t("luckperms_remove_perm", { node: p.node })} aria-label={t("luckperms_remove_perm", { node: p.node })}
title={t("luckperms_remove_perm", { node: p.node })} title={t("luckperms_remove_perm", { node: p.node })}
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive hover:bg-muted rounded transition-all focus:outline-none" className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive hover:bg-muted rounded transition-all focus:outline-none"
@@ -834,9 +857,16 @@ export function ServerLuckPerms() {
<div className="divide-y divide-border/60 max-h-[320px] overflow-y-auto pr-1"> <div className="divide-y divide-border/60 max-h-[320px] overflow-y-auto pr-1">
{history.map((h) => { {history.map((h) => {
const isSuccess = h.status === "success"; const isSuccess = h.status === "success";
const shownValue = h.value === undefined ? "" : ` (${h.value ? "TRUE" : "FALSE"})`;
const actionLabel = h.type === "group" const actionLabel = h.type === "group"
? `${h.action === "add" ? "+" : "-"} ${t("luckperms_group_name")}: ${h.target}` ? `${h.action === "add" ? "+" : "-"} ${t("luckperms_group_name")}: ${h.target}`
: `${h.action === "set" ? `+ ${h.target} (${h.value ? "TRUE" : "FALSE"})` : `- ${h.target}`}${h.world ? ` [${h.world}]` : ""}`; : `${h.action === "set" ? "+" : "-"} ${h.target}${shownValue}${h.world ? ` [${h.world}]` : ""}`;
const revertible = h.type === "group" || !!h.undo;
const revertTitle = h.undo
? h.undo.action === "set"
? t("luckperms_revert_to_set", { node: h.target, value: h.undo.value ? "TRUE" : "FALSE" })
: t("luckperms_revert_to_unset", { node: h.target })
: undefined;
return ( return (
<div <div
@@ -860,18 +890,24 @@ export function ServerLuckPerms() {
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-muted-foreground font-mono select-none">{h.timestamp}</span> <span className="text-[10px] text-muted-foreground font-mono select-none">{h.timestamp}</span>
{isSuccess && !h.reverted && ( {isSuccess && !h.reverted && revertible && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={submitting} disabled={submitting}
onClick={() => handleRevert(h)} onClick={() => handleRevert(h)}
title={revertTitle}
className="h-6 text-[10px] text-muted-foreground hover:text-primary py-0 px-2 gap-1 focus:outline-none" className="h-6 text-[10px] text-muted-foreground hover:text-primary py-0 px-2 gap-1 focus:outline-none"
> >
<Undo className="h-3 w-3" /> <Undo className="h-3 w-3" />
{t("luckperms_revert")} {t("luckperms_revert")}
</Button> </Button>
)} )}
{isSuccess && !h.reverted && !revertible && (
<span className="text-[10px] text-muted-foreground select-none">
{t("luckperms_revert_unknown")}
</span>
)}
{h.reverted && ( {h.reverted && (
<Badge variant="outline" className="text-[9px] h-5 border-emerald-500/30 text-emerald-600 bg-emerald-500/10 font-bold px-1.5 select-none uppercase"> <Badge variant="outline" className="text-[9px] h-5 border-emerald-500/30 text-emerald-600 bg-emerald-500/10 font-bold px-1.5 select-none uppercase">
{t("luckperms_revert")} {t("luckperms_revert")}