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_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",
|
||||||
|
|||||||
@@ -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": "尚未分配任何权限节点",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user