feat(access): 服务器没装 LuckPerms 时权限与用户组操作返回 409 luckperms_missing,面板说明原因

This commit is contained in:
Lemon-miaow committed 2026-09-26 07:35:20 +08:00
1 parent aa6e487ff9
commit 201cfc864e
9 files changed
+178 -10

No files matched your search

@@ -16,6 +16,7 @@
"cooldown": "Wake is cooling down — try again shortly.",
"not_running": "The server isn't running — wake it before managing access.",
"console_unavailable": "Can't reach the server console right now — try again shortly.",
"luckperms_missing": "LuckPerms isn't installed on this server, so permission and group changes have no effect. For the lobby, ask the operator to re-run the installer; any other server needs the LuckPerms plugin added first.",
"no_backup": "There's no restorable backup for this server yet.",
"backup_corrupt": "This backup failed its read-back check and can't be restored intact — pick another backup.",
"not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.",
@@ -16,6 +16,7 @@
"cooldown": "启动冷却中——请稍后再试。",
"not_running": "服务器未在运行——请先启动它再管理访问权限。",
"console_unavailable": "暂时无法连接服务器控制台,请稍后重试。",
"luckperms_missing": "这台服务器没有装 LuckPerms,权限和用户组的改动不会生效。大厅请让运维重跑安装脚本来补上;其他服务器需要先装 LuckPerms 插件。",
"no_backup": "这台服务器暂时没有可回档的备份。",
"backup_corrupt": "这份备份回读校验未通过,已无法完整恢复——请选择另一份备份。",
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。",
+2
View File
@@ -1035,6 +1035,8 @@ export function humanizeError(e: unknown): string {
return t("not_running");
case "console_unavailable":
return t("console_unavailable");
case "luckperms_missing":
return t("luckperms_missing");
// World restore (spec §7 restore-backup): the world volume must be free, so a
// running/starting server 409s not_stopped; no present backup 404s no_backup;
// the restore subsystem may be unwired (503 restore_unavailable).
+3 -3
View File
@@ -4189,7 +4189,7 @@ export interface operations {
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @description Server not running. */
/** @description not_running (the server is not running) or luckperms_missing (the server answered the lp command as unknown: LuckPerms is not installed, and nothing changed). */
409: {
headers: {
[name: string]: unknown;
@@ -4226,7 +4226,7 @@ export interface operations {
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @description Server not running. */
/** @description not_running (the server is not running) or luckperms_missing (the server answered the lp command as unknown: LuckPerms is not installed, and nothing changed). */
409: {
headers: {
[name: string]: unknown;
@@ -4274,7 +4274,7 @@ export interface operations {
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @description Server not running. */
/** @description not_running (the server is not running) or luckperms_missing (the server answered the lp command as unknown: LuckPerms is not installed, and nothing changed). */
409: {
headers: {
[name: string]: unknown;
+75
View File
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { ServerLuckPerms } from "./ServerLuckPerms";
const calls = vi.hoisted(() => ({
status: vi.fn(),
myServers: vi.fn(),
accessPlayers: vi.fn(),
accessLuckPermsInfo: vi.fn(),
accessGroup: vi.fn(),
}));
vi.mock("@/lib/tier", () => ({
useTier: () => ({
loading: false,
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
isAdmin: true,
isOwner: false,
}),
}));
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
const missing = { status: 409, code: "luckperms_missing", message: "LuckPerms is not installed on this server" };
beforeEach(() => {
for (const fn of Object.values(calls)) fn.mockReset();
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Running" });
calls.myServers.mockResolvedValue([]);
calls.accessPlayers.mockResolvedValue({ online: 0, max: 20, players: [], output: "" });
calls.accessLuckPermsInfo.mockRejectedValue(missing);
calls.accessGroup.mockRejectedValue(missing);
});
afterEach(() => {
vi.restoreAllMocks();
return i18next.changeLanguage("en-US");
});
function renderPage() {
return render(
<MemoryRouter initialEntries={["/servers/lobby/luckperms"]}>
<Routes>
<Route path="/servers/:name/luckperms" element={<ServerLuckPerms />} />
</Routes>
</MemoryRouter>,
);
}
// A server without LuckPerms (#4): the read of a player says why it came back
// empty, and a write is refused with the same cause and never lands in the
// history as a success.
describe("ServerLuckPerms without LuckPerms", () => {
it("says LuckPerms is missing on the read and on a refused write", async () => {
renderPage();
await userEvent.type(await screen.findByPlaceholderText("Steve"), "Alex{Enter}");
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/LuckPerms isn't installed/);
expect(calls.accessLuckPermsInfo).toHaveBeenCalledWith("lobby", "Alex");
await userEvent.type(screen.getByLabelText("Group Name"), "vip");
await userEvent.click(screen.getByRole("button", { name: /add parent group/i }));
expect(calls.accessGroup).toHaveBeenCalledWith("lobby", "add", "Alex", "vip");
const alerts = await screen.findAllByRole("alert");
expect(alerts).toHaveLength(2);
expect(alerts.every((a) => /LuckPerms isn't installed/.test(a.textContent ?? ""))).toBe(true);
expect(screen.queryByText(/success/i)).toBeNull();
});
});
+9 -1
View File
@@ -84,7 +84,7 @@ export function ServerLuckPerms() {
const [searchQuery, setSearchQuery] = useState("");
// LuckPerms Profile data for selected player
const { data: lpInfo, loading: lpLoading, reload: reloadLp } = useAsync(
const { data: lpInfo, error: lpError, loading: lpLoading, reload: reloadLp } = useAsync(
() => selectedPlayer ? api.accessLuckPermsInfo(name, selectedPlayer) : Promise.resolve(null),
[name, selectedPlayer]
);
@@ -427,6 +427,14 @@ export function ServerLuckPerms() {
</details>
)}
{/* A read that failed (no LuckPerms on the server, console down) says
why here; the panels below would otherwise sit empty with no cause. */}
{lpError && (
<div role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
{humanizeError(lpError)}
</div>
)}
{lpSilent && (
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-xs text-muted-foreground">
{t("luckperms_no_reply")}