feat(panel): implement dedicated LuckPerms permissions and groups management sub-page
This commit is contained in:
7 files changed
+1125
-4
No files matched your search
+152
-1
@@ -38,12 +38,18 @@ interface MockServer extends ServerInfo {
|
|||||||
owner: AccountID | null;
|
owner: AccountID | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlayerLuckPerms {
|
||||||
|
groups: string[];
|
||||||
|
permissions: { node: string; value: boolean; world?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
interface AccessState {
|
interface AccessState {
|
||||||
whitelist: string[];
|
whitelist: string[];
|
||||||
banned: string[];
|
banned: string[];
|
||||||
// online is the mock's stand-in for the live RCON "list" roster. Kick and ban
|
// 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.
|
// splice a player out of it so the demo roster reflects the action on reload.
|
||||||
online: string[];
|
online: string[];
|
||||||
|
luckperms: Record<string, PlayerLuckPerms>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockState {
|
interface MockState {
|
||||||
@@ -62,6 +68,8 @@ interface MockState {
|
|||||||
// rejects a malformed player exactly as the real API would (400 bad_request),
|
// rejects a malformed player exactly as the real API would (400 bad_request),
|
||||||
// keeping the panel's error path exercisable in dev.
|
// keeping the panel's error path exercisable in dev.
|
||||||
const PLAYER_NAME = /^[A-Za-z0-9_]{1,16}$/;
|
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 {
|
interface RequestContext {
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
@@ -240,6 +248,22 @@ function initialState(): MockState {
|
|||||||
"mock_player", "test_player", "Notch", "Steve", "Alex", "jeb_",
|
"mock_player", "test_player", "Notch", "Steve", "Alex", "jeb_",
|
||||||
"Dinnerbone", "Griefer_88", "rndGuest_7", "xX_Raider_Xx", "creeper_fan", "Herobrine",
|
"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(),
|
backups: mockBackups(),
|
||||||
@@ -1277,7 +1301,7 @@ async function handleRestoreBackupMock(ctx: SessionContext, serverInfo: MockServ
|
|||||||
function accessFor(state: MockState, name: string): AccessState {
|
function accessFor(state: MockState, name: string): AccessState {
|
||||||
let entry = state.access[name];
|
let entry = state.access[name];
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = { whitelist: [], banned: [], online: [] };
|
entry = { whitelist: [], banned: [], online: [], luckperms: {} };
|
||||||
state.access[name] = entry;
|
state.access[name] = entry;
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
@@ -1326,6 +1350,21 @@ function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean
|
|||||||
const sub = ctx.parts[5];
|
const sub = ctx.parts[5];
|
||||||
const access = accessFor(ctx.state, serverInfo.name);
|
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") {
|
if (is("GET", ctx) && sub === "whitelist") {
|
||||||
sendJSON(ctx.res, 200, {
|
sendJSON(ctx.res, 200, {
|
||||||
name: serverInfo.name,
|
name: serverInfo.name,
|
||||||
@@ -1365,6 +1404,14 @@ function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean
|
|||||||
void handleListMutation(ctx, serverInfo, access, "ban");
|
void handleListMutation(ctx, serverInfo, access, "ban");
|
||||||
return true;
|
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;
|
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
|
// handleListMutation backs both POST .../access/whitelist (add|remove) and
|
||||||
// POST .../access/ban (ban|pardon): the same structured {action, player} shape with
|
// POST .../access/ban (ban|pardon): the same structured {action, player} shape with
|
||||||
// a charset-validated player, echoing back {name, action, player, output}.
|
// 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 { ServerConsole } from "@/pages/ServerConsole";
|
||||||
import { ServerPlayers } from "@/pages/ServerPlayers";
|
import { ServerPlayers } from "@/pages/ServerPlayers";
|
||||||
import { ServerBackups } from "@/pages/ServerBackups";
|
import { ServerBackups } from "@/pages/ServerBackups";
|
||||||
|
import { ServerLuckPerms } from "@/pages/ServerLuckPerms";
|
||||||
import { Account } from "@/pages/Account";
|
import { Account } from "@/pages/Account";
|
||||||
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
||||||
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
||||||
@@ -47,6 +48,7 @@ export default function App() {
|
|||||||
<Route path="servers/:name" element={<ServerConsole />} />
|
<Route path="servers/:name" element={<ServerConsole />} />
|
||||||
<Route path="servers/:name/players" element={<ServerPlayers />} />
|
<Route path="servers/:name/players" element={<ServerPlayers />} />
|
||||||
<Route path="servers/:name/backups" element={<ServerBackups />} />
|
<Route path="servers/:name/backups" element={<ServerBackups />} />
|
||||||
|
<Route path="servers/:name/luckperms" element={<ServerLuckPerms />} />
|
||||||
<Route path="submissions" element={<MySubmissionsPage />} />
|
<Route path="submissions" element={<MySubmissionsPage />} />
|
||||||
<Route path="account" element={<Account />} />
|
<Route path="account" element={<Account />} />
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
"players_title": "Player management",
|
"players_title": "Player management",
|
||||||
"players_link_title": "Player management",
|
"players_link_title": "Player management",
|
||||||
"players_link_desc": "Manage the whitelist, online players, and bans.",
|
"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_title": "Backups & restore",
|
||||||
"backups_link_desc": "View and restore world backups for this server.",
|
"backups_link_desc": "View and restore world backups for this server.",
|
||||||
"players_back_to_console": "Back to console",
|
"players_back_to_console": "Back to console",
|
||||||
@@ -135,5 +137,54 @@
|
|||||||
"edit_server_unchanged": "Leave unchanged",
|
"edit_server_unchanged": "Leave unchanged",
|
||||||
"edit_server_immutable": "Unchanged (Immutable)",
|
"edit_server_immutable": "Unchanged (Immutable)",
|
||||||
"edit_server_submit": "Save Config",
|
"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_title": "玩家管理",
|
||||||
"players_link_title": "玩家管理",
|
"players_link_title": "玩家管理",
|
||||||
"players_link_desc": "管理白名单、在线玩家与封禁。",
|
"players_link_desc": "管理白名单、在线玩家与封禁。",
|
||||||
|
"luckperms_link_title": "LuckPerms 权限",
|
||||||
|
"luckperms_link_desc": "管理玩家用户组与细粒度权限节点。",
|
||||||
"backups_link_title": "备份与恢复",
|
"backups_link_title": "备份与恢复",
|
||||||
"backups_link_desc": "查看并恢复该服务器的世界备份。",
|
"backups_link_desc": "查看并恢复该服务器的世界备份。",
|
||||||
"players_back_to_console": "返回控制台",
|
"players_back_to_console": "返回控制台",
|
||||||
@@ -135,5 +137,54 @@
|
|||||||
"edit_server_unchanged": "保持不变",
|
"edit_server_unchanged": "保持不变",
|
||||||
"edit_server_immutable": "保持不变 (不可修改)",
|
"edit_server_immutable": "保持不变 (不可修改)",
|
||||||
"edit_server_submit": "保存配置",
|
"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) =>
|
accessKick: (name: string, player: string) =>
|
||||||
request<KickResult>("POST", `/servers/${name}/access/kick`, { player }),
|
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: () =>
|
listImages: () =>
|
||||||
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
|
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
|
import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -282,6 +282,21 @@ export function ServerConsole() {
|
|||||||
onUpdated={reload}
|
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),
|
{/* Player management lives on its own subpage (whitelist / online / bans),
|
||||||
not crammed under the console. This is the doorway to it; the page
|
not crammed under the console. This is the doorway to it; the page
|
||||||
itself owns the ownership + readiness gating. */}
|
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