Loading panel/src/i18n/resources/en-US/servers.json +3 −0 Changes for panel/src/i18n/resources/en-US/servers.json: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -175,6 +175,9 @@ "luckperms_recent_actions": "Recent Actions History", "luckperms_no_recent_actions": "No recent actions.", "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_perm": "Remove permission {{node}}", "luckperms_reverting": "Reverting...", Loading panel/src/i18n/resources/zh-CN/servers.json +3 −0 Changes for panel/src/i18n/resources/zh-CN/servers.json: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -175,6 +175,9 @@ "luckperms_recent_actions": "最近操作历史", "luckperms_no_recent_actions": "暂无最近操作历史。", "luckperms_revert": "撤销", "luckperms_revert_to_set": "撤销会把 {{node}} 改回 {{value}}", "luckperms_revert_to_unset": "撤销会移除 {{node}}", "luckperms_revert_unknown": "无法撤销:原值未知", "luckperms_remove_group": "移除权限组 {{group}}", "luckperms_remove_perm": "移除权限 {{node}}", "luckperms_reverting": "正在撤销...", Loading panel/src/pages/ServerLuckPerms.test.tsx +105 −0 Changes for panel/src/pages/ServerLuckPerms.test.tsx: 105 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -181,3 +181,108 @@ describe("ServerLuckPerms when LuckPerms does not answer", () => { 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(); }); }); panel/src/pages/ServerLuckPerms.tsx +44 −8 Changes for panel/src/pages/ServerLuckPerms.tsx: 44 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -48,6 +48,10 @@ interface ActionHistoryItem { target: string; value?: boolean; 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"; output: string; reverted?: boolean; Loading Loading @@ -207,12 +211,26 @@ export function ServerLuckPerms() { 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) => { e.preventDefault(); if (!selectedPlayer || !permNodeInput.trim() || submitting) return; const typed = typedPermission(); if (!typed) return; 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); setFormFeedback(null); Loading @@ -225,6 +243,7 @@ export function ServerLuckPerms() { target: node, value: permValueInput, world: world || undefined, undo: prior === undefined ? { action: "unset" } : { action: "set", value: prior }, status: "success", output: res.output || t("luckperms_no_output"), }); Loading @@ -239,7 +258,8 @@ export function ServerLuckPerms() { }; // 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; setSubmitting(true); setFormFeedback(null); Loading @@ -250,7 +270,9 @@ export function ServerLuckPerms() { player: selectedPlayer, action: "unset", target: node, value, world: world || undefined, undo: value === undefined ? undefined : { action: "set", value }, status: "success", output: res.output || t("luckperms_no_output"), }); Loading @@ -270,7 +292,7 @@ export function ServerLuckPerms() { if (!selectedPlayer || !permNodeInput.trim() || submitting) return; const typed = typedPermission(); if (!typed) return; if (await handleRemovePermission(typed.node, typed.world)) { if (await handleRemovePermission(typed.node, typed.world, priorValue(typed.node, typed.world))) { setPermNodeInput(""); setPermWorldInput(""); } Loading @@ -286,13 +308,14 @@ export function ServerLuckPerms() { const inverseAction = item.action === "add" ? "remove" : "add"; await api.accessGroup(name, inverseAction, item.player, item.target); } else { const inverseAction = item.action === "set" ? "unset" : "set"; const undo = item.undo; if (!undo) return; await api.accessPermission( name, inverseAction, undo.action, item.player, item.target, item.action === "set" ? item.value : undefined, undo.action === "set" ? undo.value : undefined, item.world || undefined ); } Loading Loading @@ -671,7 +694,7 @@ export function ServerLuckPerms() { variant="ghost" size="sm" 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 })} 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" Loading Loading @@ -834,9 +857,16 @@ export function ServerLuckPerms() { <div className="divide-y divide-border/60 max-h-[320px] overflow-y-auto pr-1"> {history.map((h) => { const isSuccess = h.status === "success"; const shownValue = h.value === undefined ? "" : ` (${h.value ? "TRUE" : "FALSE"})`; const actionLabel = h.type === "group" ? `${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 ( <div Loading @@ -860,18 +890,24 @@ export function ServerLuckPerms() { </div> <div className="flex items-center gap-2 shrink-0"> <span className="text-[10px] text-muted-foreground font-mono select-none">{h.timestamp}</span> {isSuccess && !h.reverted && ( {isSuccess && !h.reverted && revertible && ( <Button variant="outline" size="sm" disabled={submitting} 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" > <Undo className="h-3 w-3" /> {t("luckperms_revert")} </Button> )} {isSuccess && !h.reverted && !revertible && ( <span className="text-[10px] text-muted-foreground select-none"> {t("luckperms_revert_unknown")} </span> )} {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"> {t("luckperms_revert")} Loading Loading
panel/src/i18n/resources/en-US/servers.json +3 −0 Changes for panel/src/i18n/resources/en-US/servers.json: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -175,6 +175,9 @@ "luckperms_recent_actions": "Recent Actions History", "luckperms_no_recent_actions": "No recent actions.", "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_perm": "Remove permission {{node}}", "luckperms_reverting": "Reverting...", Loading
panel/src/i18n/resources/zh-CN/servers.json +3 −0 Changes for panel/src/i18n/resources/zh-CN/servers.json: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -175,6 +175,9 @@ "luckperms_recent_actions": "最近操作历史", "luckperms_no_recent_actions": "暂无最近操作历史。", "luckperms_revert": "撤销", "luckperms_revert_to_set": "撤销会把 {{node}} 改回 {{value}}", "luckperms_revert_to_unset": "撤销会移除 {{node}}", "luckperms_revert_unknown": "无法撤销:原值未知", "luckperms_remove_group": "移除权限组 {{group}}", "luckperms_remove_perm": "移除权限 {{node}}", "luckperms_reverting": "正在撤销...", Loading
panel/src/pages/ServerLuckPerms.test.tsx +105 −0 Changes for panel/src/pages/ServerLuckPerms.test.tsx: 105 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -181,3 +181,108 @@ describe("ServerLuckPerms when LuckPerms does not answer", () => { 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(); }); });
panel/src/pages/ServerLuckPerms.tsx +44 −8 Changes for panel/src/pages/ServerLuckPerms.tsx: 44 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -48,6 +48,10 @@ interface ActionHistoryItem { target: string; value?: boolean; 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"; output: string; reverted?: boolean; Loading Loading @@ -207,12 +211,26 @@ export function ServerLuckPerms() { 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) => { e.preventDefault(); if (!selectedPlayer || !permNodeInput.trim() || submitting) return; const typed = typedPermission(); if (!typed) return; 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); setFormFeedback(null); Loading @@ -225,6 +243,7 @@ export function ServerLuckPerms() { target: node, value: permValueInput, world: world || undefined, undo: prior === undefined ? { action: "unset" } : { action: "set", value: prior }, status: "success", output: res.output || t("luckperms_no_output"), }); Loading @@ -239,7 +258,8 @@ export function ServerLuckPerms() { }; // 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; setSubmitting(true); setFormFeedback(null); Loading @@ -250,7 +270,9 @@ export function ServerLuckPerms() { player: selectedPlayer, action: "unset", target: node, value, world: world || undefined, undo: value === undefined ? undefined : { action: "set", value }, status: "success", output: res.output || t("luckperms_no_output"), }); Loading @@ -270,7 +292,7 @@ export function ServerLuckPerms() { if (!selectedPlayer || !permNodeInput.trim() || submitting) return; const typed = typedPermission(); if (!typed) return; if (await handleRemovePermission(typed.node, typed.world)) { if (await handleRemovePermission(typed.node, typed.world, priorValue(typed.node, typed.world))) { setPermNodeInput(""); setPermWorldInput(""); } Loading @@ -286,13 +308,14 @@ export function ServerLuckPerms() { const inverseAction = item.action === "add" ? "remove" : "add"; await api.accessGroup(name, inverseAction, item.player, item.target); } else { const inverseAction = item.action === "set" ? "unset" : "set"; const undo = item.undo; if (!undo) return; await api.accessPermission( name, inverseAction, undo.action, item.player, item.target, item.action === "set" ? item.value : undefined, undo.action === "set" ? undo.value : undefined, item.world || undefined ); } Loading Loading @@ -671,7 +694,7 @@ export function ServerLuckPerms() { variant="ghost" size="sm" 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 })} 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" Loading Loading @@ -834,9 +857,16 @@ export function ServerLuckPerms() { <div className="divide-y divide-border/60 max-h-[320px] overflow-y-auto pr-1"> {history.map((h) => { const isSuccess = h.status === "success"; const shownValue = h.value === undefined ? "" : ` (${h.value ? "TRUE" : "FALSE"})`; const actionLabel = h.type === "group" ? `${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 ( <div Loading @@ -860,18 +890,24 @@ export function ServerLuckPerms() { </div> <div className="flex items-center gap-2 shrink-0"> <span className="text-[10px] text-muted-foreground font-mono select-none">{h.timestamp}</span> {isSuccess && !h.reverted && ( {isSuccess && !h.reverted && revertible && ( <Button variant="outline" size="sm" disabled={submitting} 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" > <Undo className="h-3 w-3" /> {t("luckperms_revert")} </Button> )} {isSuccess && !h.reverted && !revertible && ( <span className="text-[10px] text-muted-foreground select-none"> {t("luckperms_revert_unknown")} </span> )} {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"> {t("luckperms_revert")} Loading