feat(panel): implement dedicated LuckPerms permissions and groups management sub-page

This commit is contained in:
Lemon-miaow committed 2026-07-04 00:27:09 +08:00
1 parent 5f402b190c
commit b7d8000eaf
7 files changed
+1126 -5

No files matched your search

+152 -1
View File
@@ -38,12 +38,18 @@ interface MockServer extends ServerInfo {
owner: AccountID | null;
}
interface PlayerLuckPerms {
groups: string[];
permissions: { node: string; value: boolean; world?: string }[];
}
interface AccessState {
whitelist: string[];
banned: string[];
// online is the mock's stand-in for the live RCON "list" roster. Kick and ban
// splice a player out of it so the demo roster reflects the action on reload.
online: string[];
luckperms: Record<string, PlayerLuckPerms>;
}
interface MockState {
@@ -62,6 +68,8 @@ interface MockState {
// rejects a malformed player exactly as the real API would (400 bad_request),
// keeping the panel's error path exercisable in dev.
const PLAYER_NAME = /^[A-Za-z0-9_]{1,16}$/;
const LP_NODE = /^[A-Za-z0-9_.*-]{1,64}$/;
const LP_CTX = /^[A-Za-z0-9_-]{1,48}$/;
interface RequestContext {
req: IncomingMessage;
@@ -240,6 +248,22 @@ function initialState(): MockState {
"mock_player", "test_player", "Notch", "Steve", "Alex", "jeb_",
"Dinnerbone", "Griefer_88", "rndGuest_7", "xX_Raider_Xx", "creeper_fan", "Herobrine",
],
luckperms: {
Steve: {
groups: ["vip", "default"],
permissions: [
{ node: "essentials.fly", value: true },
{ node: "essentials.tpa", value: true },
{ node: "minecraft.command.gamemode", value: false },
],
},
Alex: {
groups: ["default"],
permissions: [
{ node: "essentials.tpa", value: true },
],
},
},
},
},
backups: mockBackups(),
@@ -1277,7 +1301,7 @@ async function handleRestoreBackupMock(ctx: SessionContext, serverInfo: MockServ
function accessFor(state: MockState, name: string): AccessState {
let entry = state.access[name];
if (!entry) {
entry = { whitelist: [], banned: [], online: [] };
entry = { whitelist: [], banned: [], online: [], luckperms: {} };
state.access[name] = entry;
}
return entry;
@@ -1326,6 +1350,21 @@ function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean
const sub = ctx.parts[5];
const access = accessFor(ctx.state, serverInfo.name);
if (is("GET", ctx) && sub === "luckperms") {
const player = ctx.parts[6];
if (!player || !PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return true;
}
const lpData = access.luckperms[player] || { groups: ["default"], permissions: [] };
sendJSON(ctx.res, 200, {
player,
groups: lpData.groups,
permissions: lpData.permissions,
});
return true;
}
if (is("GET", ctx) && sub === "whitelist") {
sendJSON(ctx.res, 200, {
name: serverInfo.name,
@@ -1365,6 +1404,14 @@ function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean
void handleListMutation(ctx, serverInfo, access, "ban");
return true;
}
if (is("POST", ctx) && sub === "group") {
void handleGroupMock(ctx, serverInfo);
return true;
}
if (is("POST", ctx) && sub === "permission") {
void handlePermissionMock(ctx, serverInfo);
return true;
}
return false;
}
@@ -1391,6 +1438,110 @@ async function handleKickMock(
});
}
async function handleGroupMock(
ctx: SessionContext,
serverInfo: MockServer,
): Promise<void> {
const body = await readJSON<{ action?: string; player?: string; group?: string }>(ctx.req);
const player = body.player?.trim() ?? "";
const group = body.group?.trim() ?? "";
const action = body.action?.trim() ?? "";
if (!PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return;
}
if (!LP_CTX.test(group)) {
sendError(ctx.res, 400, "bad_request", "invalid group name");
return;
}
if (action !== "add" && action !== "remove") {
sendError(ctx.res, 400, "bad_request", "invalid action");
return;
}
const access = accessFor(ctx.state, serverInfo.name);
if (!access.luckperms[player]) {
access.luckperms[player] = { groups: ["default"], permissions: [] };
}
const lp = access.luckperms[player];
if (action === "add") {
if (!lp.groups.includes(group)) lp.groups.push(group);
} else if (action === "remove") {
lp.groups = lp.groups.filter((g) => g !== group);
}
sendJSON(ctx.res, 200, {
name: serverInfo.name,
action,
player,
group,
output: `[mock-luckperms] Added parent group '${group}' for user ${player} in context global`,
});
}
async function handlePermissionMock(
ctx: SessionContext,
serverInfo: MockServer,
): Promise<void> {
const body = await readJSON<{
action?: string;
player?: string;
node?: string;
value?: boolean;
world?: string;
}>(ctx.req);
const player = body.player?.trim() ?? "";
const node = body.node?.trim() ?? "";
const action = body.action?.trim() ?? "";
const value = body.value ?? true;
const world = body.world?.trim() ?? "";
if (!PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return;
}
if (!LP_NODE.test(node)) {
sendError(ctx.res, 400, "bad_request", "invalid permission node");
return;
}
if (world !== "" && !LP_CTX.test(world)) {
sendError(ctx.res, 400, "bad_request", "invalid world context");
return;
}
if (action !== "set" && action !== "unset") {
sendError(ctx.res, 400, "bad_request", "invalid action");
return;
}
const access = accessFor(ctx.state, serverInfo.name);
if (!access.luckperms[player]) {
access.luckperms[player] = { groups: ["default"], permissions: [] };
}
const lp = access.luckperms[player];
if (action === "set") {
lp.permissions = lp.permissions.filter((p) => !(p.node === node && p.world === world));
lp.permissions.push({ node, value, world: world || undefined });
} else if (action === "unset") {
lp.permissions = lp.permissions.filter((p) => !(p.node === node && p.world === world));
}
const worldSuffix = world ? ` (world=${world})` : "";
const outputMsg = action === "set"
? `[mock-luckperms] Set permission ${node} to ${value} for ${player}${worldSuffix}`
: `[mock-luckperms] Unset permission ${node} for ${player}${worldSuffix}`;
sendJSON(ctx.res, 200, {
name: serverInfo.name,
action,
player,
node,
value,
world: world || undefined,
output: outputMsg,
});
}
// handleListMutation backs both POST .../access/whitelist (add|remove) and
// POST .../access/ban (ban|pardon): the same structured {action, player} shape with
// a charset-validated player, echoing back {name, action, player, output}.
+2
View File
@@ -11,6 +11,7 @@ import { ServersPage } from "@/pages/servers/ServersPage";
import { ServerConsole } from "@/pages/ServerConsole";
import { ServerPlayers } from "@/pages/ServerPlayers";
import { ServerBackups } from "@/pages/ServerBackups";
import { ServerLuckPerms } from "@/pages/ServerLuckPerms";
import { Account } from "@/pages/Account";
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
@@ -47,6 +48,7 @@ export default function App() {
<Route path="servers/:name" element={<ServerConsole />} />
<Route path="servers/:name/players" element={<ServerPlayers />} />
<Route path="servers/:name/backups" element={<ServerBackups />} />
<Route path="servers/:name/luckperms" element={<ServerLuckPerms />} />
<Route path="submissions" element={<MySubmissionsPage />} />
<Route path="account" element={<Account />} />
+52 -1
View File
@@ -47,6 +47,8 @@
"players_title": "Player management",
"players_link_title": "Player management",
"players_link_desc": "Manage the whitelist, online players, and bans.",
"luckperms_link_title": "LuckPerms Permissions",
"luckperms_link_desc": "Manage player groups and fine-grained permission nodes.",
"backups_link_title": "Backups & restore",
"backups_link_desc": "View and restore world backups for this server.",
"players_back_to_console": "Back to console",
@@ -135,5 +137,54 @@
"edit_server_unchanged": "Leave unchanged",
"edit_server_immutable": "Unchanged (Immutable)",
"edit_server_submit": "Save Config",
"edit_server_updating": "Saving…"
"edit_server_updating": "Saving…",
"luckperms_dialog_title": "LuckPerms Permissions",
"luckperms_dialog_desc": "Set or unset LuckPerms permission nodes and parent groups on the running server (requires the LuckPerms plugin to be active).",
"luckperms_tab_group": "Groups",
"luckperms_tab_permission": "Permissions",
"luckperms_player_name": "Player Username",
"luckperms_group_name": "Group Name",
"luckperms_node": "Permission Node",
"luckperms_action": "Action",
"luckperms_action_add": "Add to Group (add)",
"luckperms_action_remove": "Remove from Group (remove)",
"luckperms_action_set": "Set Permission (set)",
"luckperms_action_unset": "Unset Permission (unset)",
"luckperms_value": "Value",
"luckperms_value_grant": "Grant (True)",
"luckperms_value_deny": "Deny (False)",
"luckperms_world": "World Context (Optional)",
"luckperms_confirm": "Apply Changes",
"luckperms_executing": "Executing command…",
"luckperms_success": "LuckPerms command executed successfully:",
"luckperms_error_invalid_player": "Invalid player name (1–16 chars: letters, digits, underscore)",
"luckperms_error_invalid_node": "Invalid permission node (allowed: letters, digits, ., -, _, *, 1-64 chars)",
"luckperms_error_invalid_group": "Invalid group name (allowed: letters, digits, -, _, 1-48 chars)",
"luckperms_error_invalid_world": "Invalid world context (allowed: letters, digits, -, _, 1-48 chars)",
"luckperms_title": "LuckPerms Permissions",
"luckperms_desc": "Manage player permission nodes and parent groups on this server (requires the LuckPerms plugin to be active).",
"luckperms_back_to_console": "Back to console",
"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_groups_title": "Parent Groups",
"luckperms_permissions_title": "Permission Nodes",
"luckperms_node_column": "Permission Node",
"luckperms_value_column": "Value",
"luckperms_world_column": "World",
"luckperms_actions_column": "Actions",
"luckperms_query_btn": "Query Player",
"luckperms_custom_group_placeholder": "Enter custom group name...",
"luckperms_batch_players": "Player Usernames List",
"luckperms_batch_players_placeholder": "Enter player names, support multiple (separated by commas or spaces)",
"luckperms_recent_actions": "Recent Actions History",
"luckperms_no_recent_actions": "No recent actions.",
"luckperms_revert": "Revert",
"luckperms_reverting": "Reverting...",
"luckperms_revert_success": "Action reverted successfully!",
"luckperms_quick_presets": "Common Presets",
"luckperms_presets": "Presets",
"luckperms_batch_status": "Batch Progress",
"luckperms_success_count": "Success: {{count}}",
"luckperms_failed_count": "Failed: {{count}}"
}
+52 -1
View File
@@ -47,6 +47,8 @@
"players_title": "玩家管理",
"players_link_title": "玩家管理",
"players_link_desc": "管理白名单、在线玩家与封禁。",
"luckperms_link_title": "LuckPerms 权限",
"luckperms_link_desc": "管理玩家用户组与细粒度权限节点。",
"backups_link_title": "备份与恢复",
"backups_link_desc": "查看并恢复该服务器的世界备份。",
"players_back_to_console": "返回控制台",
@@ -135,5 +137,54 @@
"edit_server_unchanged": "保持不变",
"edit_server_immutable": "保持不变 (不可修改)",
"edit_server_submit": "保存配置",
"edit_server_updating": "正在保存…"
"edit_server_updating": "正在保存…",
"luckperms_dialog_title": "LuckPerms 权限管理",
"luckperms_dialog_desc": "在运行中的服务器上设置或取消玩家的权限节点与用户组(要求 LuckPerms 插件处于运行状态)。",
"luckperms_tab_group": "用户组管理",
"luckperms_tab_permission": "细粒度权限",
"luckperms_player_name": "玩家用户名",
"luckperms_group_name": "用户组名称",
"luckperms_node": "权限节点",
"luckperms_action": "操作类型",
"luckperms_action_add": "添加至用户组 (add)",
"luckperms_action_remove": "从用户组移除 (remove)",
"luckperms_action_set": "设置权限节点 (set)",
"luckperms_action_unset": "取消权限节点 (unset)",
"luckperms_value": "权限值 (Value)",
"luckperms_value_grant": "允许 (True)",
"luckperms_value_deny": "拒绝 (False)",
"luckperms_world": "世界范围 context (可选)",
"luckperms_confirm": "执行变更",
"luckperms_executing": "正在执行命令…",
"luckperms_success": "LuckPerms 命令成功执行:",
"luckperms_error_invalid_player": "玩家用户名格式错误 (支持1-16位英文字母、数字和下划线)",
"luckperms_error_invalid_node": "权限节点格式错误 (支持1-64位字母、数字、点号、横线、下划线及通配符*)",
"luckperms_error_invalid_group": "用户组名称格式错误 (支持1-48位字母、数字、横线和下划线)",
"luckperms_error_invalid_world": "世界范围格式错误 (支持1-48位字母、数字、横线和下划线)",
"luckperms_title": "LuckPerms 权限管理",
"luckperms_desc": "精细化管理服务器上玩家的权限节点与用户组(需要 LuckPerms 插件处于运行状态)。",
"luckperms_back_to_console": "返回控制台",
"luckperms_select_player_prompt": "请在左侧选择在线玩家,或在上方输入玩家名进行查询",
"luckperms_add_group_btn": "添加用户组",
"luckperms_add_perm_btn": "添加权限",
"luckperms_groups_title": "所属用户组",
"luckperms_permissions_title": "拥有权限节点",
"luckperms_node_column": "权限节点",
"luckperms_value_column": "状态值",
"luckperms_world_column": "生效世界",
"luckperms_actions_column": "操作",
"luckperms_query_btn": "查询玩家",
"luckperms_custom_group_placeholder": "输入自定义组名...",
"luckperms_batch_players": "玩家用户名列表",
"luckperms_batch_players_placeholder": "输入玩家用户名,支持输入多个(用英文逗号或空格分隔)",
"luckperms_recent_actions": "最近操作历史",
"luckperms_no_recent_actions": "暂无最近操作历史。",
"luckperms_revert": "撤销",
"luckperms_reverting": "正在撤销...",
"luckperms_revert_success": "已成功撤销该操作!",
"luckperms_quick_presets": "常用快速预设",
"luckperms_presets": "预设",
"luckperms_batch_status": "批量执行进度",
"luckperms_success_count": "成功: {{count}}",
"luckperms_failed_count": "失败: {{count}}"
}
+33
View File
@@ -188,6 +188,39 @@ export const api = {
accessKick: (name: string, player: string) =>
request<KickResult>("POST", `/servers/${name}/access/kick`, { player }),
accessLuckPermsInfo: (name: string, player: string) =>
request<{
player: string;
groups: string[];
permissions: { node: string; value: boolean; world?: string }[];
}>("GET", `/servers/${name}/access/luckperms/${player}`),
accessPermission: (
name: string,
action: "set" | "unset",
player: string,
node: string,
value?: boolean,
world?: string
) =>
request<AccessResult & { node: string; value?: boolean; world?: string }>(
"POST",
`/servers/${name}/access/permission`,
{ action, player, node, value, world }
),
accessGroup: (
name: string,
action: "add" | "remove",
player: string,
group: string
) =>
request<AccessResult & { group: string }>(
"POST",
`/servers/${name}/access/group`,
{ action, player, group }
),
listImages: () =>
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
+17 -2
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react";
import { ArrowLeft, Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -268,7 +268,7 @@ export function ServerConsole() {
</Card>
</div>
{/* Right/Sidebar column: Navigation */}
{/* Right/Sidebar column: Navigation */}
<div className="flex flex-col gap-4 lg:col-span-1 shrink-0">
{isAdmin && (
<EditServerDialog
@@ -282,6 +282,21 @@ export function ServerConsole() {
onUpdated={reload}
/>
)}
{(data.owned || isAdmin) && (
<Link
to={`/servers/${name}/luckperms`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<Shield className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("luckperms_link_title")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("luckperms_link_desc")}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
)}
{/* Player management lives on its own subpage (whitelist / online / bans),
not crammed under the console. This is the doorway to it; the page
itself owns the ownership + readiness gating. */}
+818
View File
@@ -0,0 +1,818 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Moon, Shield, ShieldX, Users, Search, Plus, Trash2, Undo, History, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { PhaseBadge } from "@/components/PhaseBadge";
import { Loading, ErrorState } from "@/components/States";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils";
const MC_NAME = /^[A-Za-z0-9_]{1,16}$/;
const LP_NODE = /^[A-Za-z0-9_.*-]{1,64}$/;
const LP_CTX = /^[A-Za-z0-9_-]{1,48}$/;
const PRESET_GROUPS = ["default", "vip", "vip+", "mvp", "admin", "moderator", "helper", "builder"];
const PRESET_PERMS = [
"essentials.fly",
"essentials.tpa",
"essentials.home",
"essentials.warp",
"essentials.back",
"luckperms.use",
"minecraft.command.gamemode",
];
interface ActionHistoryItem {
id: string;
timestamp: string;
type: "group" | "permission";
player: string;
action: string;
target: string;
value?: boolean;
world?: string;
status: "success" | "error";
output: string;
reverted?: boolean;
}
function NotYours() {
const { t } = useTranslation("servers");
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
<ShieldX className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium">{t("players_not_yours_title")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("players_not_yours_body")}</p>
</div>
<Link to="/servers" className="text-sm font-medium text-primary hover:underline">
{t("my_servers_breadcrumb")}
</Link>
</div>
);
}
function NotRunning({ onWake }: { onWake: () => void }) {
const { t } = useTranslation("servers");
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center">
<Moon className="h-8 w-8 text-muted-foreground/70" />
<div>
<p className="font-medium">{t("players_not_running_title")}</p>
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
{t("players_not_running_body")}
</p>
</div>
<Button size="sm" onClick={onWake}>
{t("wake")}
</Button>
</div>
);
}
export function ServerLuckPerms() {
const { name = "" } = useParams();
const { t } = useTranslation("servers");
const { isAdmin, loading: tierLoading } = useTier();
// Server Status
const { data, error, loading, reload } = useAsync(() => api.status(name), [name]);
// Ownership verification
const {
data: mine,
error: mineError,
reload: reloadMine,
} = useAsync(
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
[isAdmin, name]
);
// Online Players
const { data: playersData, loading: playersLoading } = useAsync(
() => api.accessPlayers(name),
[name]
);
const onlinePlayers = playersData?.players ?? [];
// Selected Player Profile State
const [selectedPlayer, setSelectedPlayer] = useState<string>("");
const [searchQuery, setSearchQuery] = useState("");
// LuckPerms Profile data for selected player
const { data: lpInfo, loading: lpLoading, reload: reloadLp } = useAsync(
() => selectedPlayer ? api.accessLuckPermsInfo(name, selectedPlayer) : Promise.resolve(null),
[name, selectedPlayer]
);
// Form State
const [groupNameInput, setGroupNameInput] = useState("");
const [permNodeInput, setPermNodeInput] = useState("");
const [permValueInput, setPermValueInput] = useState(true);
const [permWorldInput, setPermWorldInput] = useState("");
// Feedback & History State
const [submitting, setSubmitting] = useState(false);
const [formFeedback, setFormFeedback] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
const [history, setHistory] = useState<ActionHistoryItem[]>([]);
const addHistoryItem = (item: Omit<ActionHistoryItem, "id" | "timestamp">) => {
const newItem: ActionHistoryItem = {
...item,
id: Math.random().toString(36).substring(2, 9),
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
};
setHistory((prev) => [newItem, ...prev].slice(0, 30));
};
const clearHistory = () => {
setHistory([]);
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const query = searchQuery.trim();
if (query && MC_NAME.test(query)) {
setSelectedPlayer(query);
setFormFeedback(null);
} else {
setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_player") });
}
};
const handleAddGroup = async (groupName: string) => {
if (!selectedPlayer || submitting) return;
setSubmitting(true);
setFormFeedback(null);
try {
const res = await api.accessGroup(name, "add", selectedPlayer, groupName);
addHistoryItem({
type: "group",
player: selectedPlayer,
action: "add",
target: groupName,
status: "success",
output: res.output || `[RCON] parent add ${groupName}`,
});
reloadLp();
} catch (err) {
setFormFeedback({ kind: "err", msg: humanizeError(err) });
} finally {
setSubmitting(false);
}
};
const handleRemoveGroup = async (groupName: string) => {
if (!selectedPlayer || submitting) return;
setSubmitting(true);
setFormFeedback(null);
try {
const res = await api.accessGroup(name, "remove", selectedPlayer, groupName);
addHistoryItem({
type: "group",
player: selectedPlayer,
action: "remove",
target: groupName,
status: "success",
output: res.output || `[RCON] parent remove ${groupName}`,
});
reloadLp();
} catch (err) {
setFormFeedback({ kind: "err", msg: humanizeError(err) });
} finally {
setSubmitting(false);
}
};
const handleAddPermission = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedPlayer || !permNodeInput.trim() || submitting) return;
const node = permNodeInput.trim();
const world = permWorldInput.trim();
if (!LP_NODE.test(node)) {
setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_node") });
return;
}
if (world !== "" && !LP_CTX.test(world)) {
setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_world") });
return;
}
setSubmitting(true);
setFormFeedback(null);
try {
const res = await api.accessPermission(name, "set", selectedPlayer, node, permValueInput, world || undefined);
addHistoryItem({
type: "permission",
player: selectedPlayer,
action: "set",
target: node,
value: permValueInput,
world: world || undefined,
status: "success",
output: res.output || `[RCON] permission set ${node}`,
});
setPermNodeInput("");
setPermWorldInput("");
reloadLp();
} catch (err) {
setFormFeedback({ kind: "err", msg: humanizeError(err) });
} finally {
setSubmitting(false);
}
};
const handleRemovePermission = async (node: string, world?: string) => {
if (!selectedPlayer || submitting) return;
setSubmitting(true);
setFormFeedback(null);
try {
const res = await api.accessPermission(name, "unset", selectedPlayer, node, undefined, world || undefined);
addHistoryItem({
type: "permission",
player: selectedPlayer,
action: "unset",
target: node,
world: world || undefined,
status: "success",
output: res.output || `[RCON] permission unset ${node}`,
});
reloadLp();
} catch (err) {
setFormFeedback({ kind: "err", msg: humanizeError(err) });
} finally {
setSubmitting(false);
}
};
const handleRevert = async (item: ActionHistoryItem) => {
if (submitting || item.reverted) return;
setSubmitting(true);
setFormFeedback(null);
try {
if (item.type === "group") {
const inverseAction = item.action === "add" ? "remove" : "add";
await api.accessGroup(name, inverseAction, item.player, item.target);
} else {
const inverseAction = item.action === "set" ? "unset" : "set";
await api.accessPermission(
name,
inverseAction,
item.player,
item.target,
item.action === "set" ? item.value : undefined,
item.world || undefined
);
}
// Mark reverted
setHistory((prev) =>
prev.map((h) => (h.id === item.id ? { ...h, reverted: true } : h))
);
setFormFeedback({ kind: "ok", msg: t("luckperms_revert_success") });
if (selectedPlayer === item.player) {
reloadLp();
}
} catch (err) {
setFormFeedback({ kind: "err", msg: `${t("luckperms_reverting")} ${humanizeError(err)}` });
} finally {
setSubmitting(false);
}
};
const back = (
<Link
to={`/servers/${name}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="h-4 w-4" /> {t("luckperms_back_to_console")}
</Link>
);
if (loading && !data) {
return (
<>
{back}
<Loading />
</>
);
}
if (error) {
return (
<>
{back}
<ErrorState error={error} onRetry={reload} />
</>
);
}
if (!data) return back;
const ownershipPending = ownershipPendingCheck(tierLoading, isAdmin, mine, mineError);
const owned = isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true);
const phase: Phase = data.phase;
const header = (
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Shield className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{data.displayName || data.name}
</h1>
<p className="text-sm text-muted-foreground">{t("luckperms_desc")}</p>
</div>
</div>
<PhaseBadge phase={phase} />
</div>
);
return (
<>
{back}
{header}
{ownershipPending ? (
<Loading />
) : mineError ? (
<ErrorState error={mineError} onRetry={reloadMine} />
) : !owned ? (
<NotYours />
) : phase !== "Running" ? (
<NotRunning onWake={() => api.wake(name).then(reload)} />
) : (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{/* Left Directory Sidebar: Single card for Search + Online Players */}
<Card className="lg:col-span-1 flex flex-col h-[calc(100vh-14rem)] lg:min-h-[500px] max-h-[700px] border-border bg-card/60 backdrop-blur-sm overflow-hidden">
<CardHeader className="p-4 pb-2 border-b border-border/50 shrink-0">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Users className="h-4 w-4 text-primary" />
{t("luckperms_batch_players")}
</CardTitle>
</CardHeader>
<div className="p-3 shrink-0">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Steve"
className="pl-9 h-9 text-xs font-mono bg-background/50 border-input focus-visible:ring-primary/30"
autoComplete="off"
disabled={submitting}
/>
</form>
</div>
<div className="px-4 py-1 shrink-0 bg-muted/40 border-y border-border/30">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{t("access_online_title")}
</span>
</div>
<CardContent className="p-2 overflow-y-auto flex-1 space-y-0.5 min-h-0">
{playersLoading && onlinePlayers.length === 0 ? (
<div className="flex justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : onlinePlayers.length === 0 ? (
<p className="text-xs text-muted-foreground/60 text-center py-8 italic select-none">
{t("access_online_empty")}
</p>
) : (
onlinePlayers.map((player) => (
<button
key={player}
type="button"
onClick={() => {
setSelectedPlayer(player);
setSearchQuery(player);
setFormFeedback(null);
}}
className={cn(
"w-full text-left px-3 py-2 rounded-md text-xs font-mono transition-all flex items-center justify-between border focus:outline-none",
selectedPlayer === player
? "bg-primary/10 border-primary/20 text-primary font-semibold"
: "hover:bg-muted/40 text-muted-foreground bg-transparent border-transparent"
)}
>
<span>{player}</span>
{selectedPlayer === player && (
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
)}
</button>
))
)}
</CardContent>
</Card>
{/* Right Main Panel */}
<div className="lg:col-span-3 space-y-6">
{!selectedPlayer ? (
<Card className="flex flex-col items-center justify-center py-24 text-center border-dashed border-border bg-card/20 min-h-[500px]">
<Shield className="h-14 w-14 text-muted-foreground/15 mb-4 animate-pulse" />
<p className="text-sm text-muted-foreground font-medium max-w-xs leading-normal">
{t("luckperms_select_player_prompt")}
</p>
</Card>
) : (
<>
{/* Clean player heading */}
<div className="pb-3 border-b border-border/60">
<span className="text-2xl font-bold font-mono text-foreground tracking-tight">
{selectedPlayer}
</span>
</div>
{/* Operations Feedback */}
{formFeedback && (
<div className={cn(
"p-3 rounded-md text-xs whitespace-pre-wrap break-all border transition-all animate-in fade-in duration-200",
formFeedback.kind === "ok"
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
: "bg-destructive/10 border-destructive/30 text-destructive"
)}>
{formFeedback.msg}
</div>
)}
{/* Groups Management Card */}
<Card className="border-border bg-card/40 shadow-sm">
<CardHeader className="p-5 pb-3 border-b border-border/50 bg-muted/20 shrink-0">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("luckperms_groups_title")}
</CardTitle>
</CardHeader>
<CardContent className="p-5 space-y-4">
{lpLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<>
<div className="flex flex-wrap gap-1.5">
{lpInfo?.groups && lpInfo.groups.length > 0 ? (
lpInfo.groups.map((group) => (
<Badge
key={group}
variant="muted"
className="font-mono text-xs py-1.5 pl-3 pr-2 gap-1.5 flex items-center border border-border/80 bg-muted/40 text-foreground hover:bg-muted"
>
{group}
<button
type="button"
onClick={() => handleRemoveGroup(group)}
disabled={submitting}
className="text-muted-foreground hover:text-destructive hover:bg-muted p-0.5 rounded transition-all focus:outline-none"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</Badge>
))
) : (
<p className="text-xs text-muted-foreground/60 italic py-1">No parent groups assigned</p>
)}
</div>
<div className="border-t border-border/50 pt-4 flex flex-col sm:flex-row gap-3 items-end">
<div className="grid gap-1.5 flex-1 w-full">
<Label className="text-xs text-muted-foreground font-semibold">
{t("luckperms_group_name")}
</Label>
<div className="flex gap-2">
<Input
placeholder={t("luckperms_custom_group_placeholder")}
value={groupNameInput}
onChange={(e) => setGroupNameInput(e.target.value)}
className="h-9 text-xs bg-background/50 border-input focus-visible:ring-primary/20"
disabled={submitting}
/>
<Select
value=""
onValueChange={(v) => setGroupNameInput(v)}
disabled={submitting}
>
<SelectTrigger className="w-[120px] h-9 text-xs bg-background/50 border-input text-muted-foreground">
<span>{t("luckperms_presets")}</span>
</SelectTrigger>
<SelectContent className="border-border">
{PRESET_GROUPS.map((g) => (
<SelectItem key={g} value={g}>{g}</SelectItem>
))}
</SelectContent>
</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>
</>
)}
</CardContent>
</Card>
{/* Permissions Management Card */}
<Card className="border-border bg-card/40 shadow-sm">
<CardHeader className="p-5 pb-3 border-b border-border/50 bg-muted/20 shrink-0">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("luckperms_permissions_title")}
</CardTitle>
</CardHeader>
<CardContent className="p-5 space-y-4">
{lpLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<>
<div className="rounded-md border border-border bg-muted/10 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-xs">
<thead>
<tr className="border-b border-border bg-muted/30 font-semibold text-muted-foreground">
<th className="px-4 py-3">{t("luckperms_node_column")}</th>
<th className="px-4 py-3 w-[90px] text-center">{t("luckperms_value_column")}</th>
<th className="px-4 py-3 w-[120px]">{t("luckperms_world_column")}</th>
<th className="px-4 py-3 w-[70px] text-center">{t("luckperms_actions_column")}</th>
</tr>
</thead>
<tbody>
{lpInfo?.permissions && lpInfo.permissions.length > 0 ? (
lpInfo.permissions.map((p, idx) => (
<tr
key={`${p.node}-${p.world || "global"}-${idx}`}
className="border-b border-border/40 last:border-0 hover:bg-muted/20 transition-colors"
>
<td className="px-4 py-2.5 font-mono break-all font-medium text-foreground">{p.node}</td>
<td className="px-4 py-2.5 text-center">
<Badge
variant={p.value ? "default" : "destructive"}
className={cn(
"text-[10px] py-0.5 px-2 font-bold font-mono tracking-wide border",
p.value
? "border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
: "border-destructive/20 bg-destructive/10 text-destructive hover:bg-destructive/15"
)}
>
{p.value ? "TRUE" : "FALSE"}
</Badge>
</td>
<td className="px-4 py-2.5">
{p.world ? (
<Badge
variant="outline"
className="font-mono text-[10px] py-0 px-1.5 border-border bg-muted/30 text-muted-foreground"
>
{p.world}
</Badge>
) : (
<span className="text-muted-foreground italic">global</span>
)}
</td>
<td className="px-4 py-2.5 text-center">
<Button
variant="ghost"
size="sm"
disabled={submitting}
onClick={() => handleRemovePermission(p.node, p.world)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive hover:bg-muted rounded transition-all focus:outline-none"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-muted-foreground/60 italic">
No explicit permission nodes assigned
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 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")}
</Label>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3.5">
<div className="sm:col-span-2 grid gap-1.5">
<Label htmlFor="lp-node-input" className="text-[10px] text-muted-foreground font-semibold">
{t("luckperms_node")}
</Label>
<div className="flex gap-2">
<Input
id="lp-node-input"
placeholder="essentials.fly"
value={permNodeInput}
onChange={(e) => setPermNodeInput(e.target.value)}
className="h-9 text-xs bg-background/50 border-input focus-visible:ring-primary/20"
disabled={submitting}
autoComplete="off"
/>
<Select
value=""
onValueChange={(v) => setPermNodeInput(v)}
disabled={submitting}
>
<SelectTrigger className="w-[100px] h-9 text-xs shrink-0 bg-background/50 border-input text-muted-foreground">
<span>{t("luckperms_presets")}</span>
</SelectTrigger>
<SelectContent className="border-border">
{PRESET_PERMS.map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="lp-value-input" className="text-[10px] text-muted-foreground font-semibold">
{t("luckperms_value")}
</Label>
<Select
value={permValueInput ? "true" : "false"}
onValueChange={(v) => setPermValueInput(v === "true")}
disabled={submitting}
>
<SelectTrigger id="lp-value-input" className="h-9 text-xs bg-background/50 border-input">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-border">
<SelectItem value="true">{t("luckperms_value_grant")}</SelectItem>
<SelectItem value="false">{t("luckperms_value_deny")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="lp-world-input" className="text-[10px] text-muted-foreground font-semibold">
{t("luckperms_world")}
</Label>
<Input
id="lp-world-input"
placeholder="world"
value={permWorldInput}
onChange={(e) => setPermWorldInput(e.target.value)}
className="h-9 text-xs font-mono bg-background/50 border-input focus-visible:ring-primary/20"
disabled={submitting}
autoComplete="off"
/>
</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>
</form>
</>
)}
</CardContent>
</Card>
</>
)}
{/* Action History Log */}
<Card className="border-border bg-card/40 shadow-sm">
<CardHeader className="p-5 pb-3 border-b border-border/50 bg-muted/20 shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<History className="h-4 w-4 text-primary" />
{t("luckperms_recent_actions")}
</CardTitle>
{history.length > 0 && (
<button
type="button"
onClick={clearHistory}
className="text-muted-foreground hover:text-destructive transition-colors focus:outline-none"
title="Clear history"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</CardHeader>
<CardContent className="p-4 pt-3">
{history.length === 0 ? (
<p className="text-xs text-muted-foreground/60 py-4 italic select-none text-center">
{t("luckperms_no_recent_actions")}
</p>
) : (
<div className="divide-y divide-border/60 max-h-[320px] overflow-y-auto pr-1">
{history.map((h) => {
const isSuccess = h.status === "success";
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}]` : ""}`;
return (
<div
key={h.id}
className={cn(
"py-3 flex flex-col gap-1.5 transition-all",
h.reverted && "opacity-45"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0 text-xs">
<span className="font-mono bg-muted border border-border px-1.5 py-0.5 rounded font-semibold text-foreground">
{h.player}
</span>
<span className={cn(
"font-mono break-all",
h.reverted ? "line-through text-muted-foreground" : isSuccess ? "text-foreground font-medium" : "text-destructive"
)}>
{actionLabel}
</span>
</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 && (
<Button
variant="outline"
size="sm"
disabled={submitting}
onClick={() => handleRevert(h)}
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>
)}
{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")}
</Badge>
)}
</div>
</div>
<details className="group/details">
<summary className="cursor-pointer select-none text-[10px] text-muted-foreground/70 hover:text-foreground font-mono transition-colors list-none flex items-center gap-1">
<span className="transition-transform group-open/details:rotate-90">▶</span>
RCON Console Output
</summary>
<pre className="mt-1.5 p-2 rounded bg-muted/60 border border-border/80 font-mono text-[10px] text-foreground/80 overflow-x-auto whitespace-pre-wrap break-all max-h-24">
{h.output}
</pre>
</details>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)}
</>
);
}
function ownershipPendingCheck(
tierLoading: boolean,
isAdmin: boolean,
mine: any[] | null,
mineError: any
): boolean {
return tierLoading || (!isAdmin && mine === null && !mineError);
}