diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index c7de17f..7d699a3 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -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; } 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 { + 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 { + 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}. diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 4e4e429..d60a009 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 69ce957..31408c3 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -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}}" } diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 1b3d862..530ef41 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -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}}" } diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 49cc976..f25030f 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -188,6 +188,39 @@ export const api = { accessKick: (name: string, player: string) => request("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( + "POST", + `/servers/${name}/access/permission`, + { action, player, node, value, world } + ), + + accessGroup: ( + name: string, + action: "add" | "remove", + player: string, + group: string + ) => + request( + "POST", + `/servers/${name}/access/group`, + { action, player, group } + ), + listImages: () => request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []), diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index ab172e1..f580723 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -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() { - {/* Right/Sidebar column: Navigation */} + {/* Right/Sidebar column: Navigation */}
{isAdmin && ( )} + {(data.owned || isAdmin) && ( + + +
+

{t("luckperms_link_title")}

+

+ {t("luckperms_link_desc")} +

+
+ + + )} {/* 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. */} diff --git a/panel/src/pages/ServerLuckPerms.tsx b/panel/src/pages/ServerLuckPerms.tsx new file mode 100644 index 0000000..7bb114d --- /dev/null +++ b/panel/src/pages/ServerLuckPerms.tsx @@ -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 ( +
+ +
+

{t("players_not_yours_title")}

+

{t("players_not_yours_body")}

+
+ + {t("my_servers_breadcrumb")} + +
+ ); +} + +function NotRunning({ onWake }: { onWake: () => void }) { + const { t } = useTranslation("servers"); + return ( +
+ +
+

{t("players_not_running_title")}

+

+ {t("players_not_running_body")} +

+
+ +
+ ); +} + +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(""); + 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([]); + + const addHistoryItem = (item: Omit) => { + 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 = ( + + {t("luckperms_back_to_console")} + + ); + + if (loading && !data) { + return ( + <> + {back} + + + ); + } + if (error) { + return ( + <> + {back} + + + ); + } + 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 = ( +
+
+ +
+

+ {data.displayName || data.name} +

+

{t("luckperms_desc")}

+
+
+ +
+ ); + + return ( + <> + {back} + {header} + {ownershipPending ? ( + + ) : mineError ? ( + + ) : !owned ? ( + + ) : phase !== "Running" ? ( + api.wake(name).then(reload)} /> + ) : ( +
+ {/* Left Directory Sidebar: Single card for Search + Online Players */} + + + + + {t("luckperms_batch_players")} + + +
+
+ + 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} + /> + +
+
+ + {t("access_online_title")} + +
+ + {playersLoading && onlinePlayers.length === 0 ? ( +
+ +
+ ) : onlinePlayers.length === 0 ? ( +

+ {t("access_online_empty")} +

+ ) : ( + onlinePlayers.map((player) => ( + + )) + )} +
+
+ + {/* Right Main Panel */} +
+ {!selectedPlayer ? ( + + +

+ {t("luckperms_select_player_prompt")} +

+
+ ) : ( + <> + {/* Clean player heading */} +
+ + {selectedPlayer} + +
+ + {/* Operations Feedback */} + {formFeedback && ( +
+ {formFeedback.msg} +
+ )} + + {/* Groups Management Card */} + + + + {t("luckperms_groups_title")} + + + + {lpLoading ? ( +
+ +
+ ) : ( + <> +
+ {lpInfo?.groups && lpInfo.groups.length > 0 ? ( + lpInfo.groups.map((group) => ( + + {group} + + + )) + ) : ( +

No parent groups assigned

+ )} +
+ +
+
+ +
+ setGroupNameInput(e.target.value)} + className="h-9 text-xs bg-background/50 border-input focus-visible:ring-primary/20" + disabled={submitting} + /> + +
+
+ +
+ + )} +
+
+ + {/* Permissions Management Card */} + + + + {t("luckperms_permissions_title")} + + + + {lpLoading ? ( +
+ +
+ ) : ( + <> +
+
+ + + + + + + + + + + {lpInfo?.permissions && lpInfo.permissions.length > 0 ? ( + lpInfo.permissions.map((p, idx) => ( + + + + + + + )) + ) : ( + + + + )} + +
{t("luckperms_node_column")}{t("luckperms_value_column")}{t("luckperms_world_column")}{t("luckperms_actions_column")}
{p.node} + + {p.value ? "TRUE" : "FALSE"} + + + {p.world ? ( + + {p.world} + + ) : ( + global + )} + + +
+ No explicit permission nodes assigned +
+
+
+ + {/* Add Permission Node Inline Form */} +
+ +
+
+ +
+ setPermNodeInput(e.target.value)} + className="h-9 text-xs bg-background/50 border-input focus-visible:ring-primary/20" + disabled={submitting} + autoComplete="off" + /> + +
+
+ +
+ + +
+ +
+ + 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" + /> +
+
+ +
+ +
+
+ + )} +
+
+ + )} + + {/* Action History Log */} + + +
+ + + {t("luckperms_recent_actions")} + + {history.length > 0 && ( + + )} +
+
+ + {history.length === 0 ? ( +

+ {t("luckperms_no_recent_actions")} +

+ ) : ( +
+ {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 ( +
+
+
+ + {h.player} + + + {actionLabel} + +
+
+ {h.timestamp} + {isSuccess && !h.reverted && ( + + )} + {h.reverted && ( + + {t("luckperms_revert")} + + )} +
+
+ +
+ + ▶ + RCON Console Output + +
+                              {h.output}
+                            
+
+
+ ); + })} +
+ )} +
+
+
+
+ )} + + ); +} + +function ownershipPendingCheck( + tierLoading: boolean, + isAdmin: boolean, + mine: any[] | null, + mineError: any +): boolean { + return tierLoading || (!isAdmin && mine === null && !mineError); +} \ No newline at end of file