feat(panel): implement dedicated LuckPerms permissions and groups management sub-page
This commit is contained in:
7 files changed
+1126
-5
No files matched your search
+152
-1
@@ -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}.
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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 ?? []),
|
||||
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user