941 lines
44 KiB
TypeScript
941 lines
44 KiB
TypeScript
import { useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { Shield, Users, Search, Plus, Trash2, Undo, History, Loader2 } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { BackLink } from "@/components/BackLink";
|
|
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, shownPhase, startFailure } from "@/components/PhaseBadge";
|
|
import { Loading, ErrorState, NotYours, NotRunning, RefreshError } from "@/components/States";
|
|
import { PageHeader } from "@/components/PageHeader";
|
|
import { api, humanizeError } from "@/lib/api";
|
|
import { STATUS_POLL_SLOW_MS, STATUS_POLL_FAST_MS, useAsync, usePolling } from "@/lib/hooks";
|
|
import { useTier } from "@/lib/tier";
|
|
import { canManage, ownershipPending } from "@/lib/ownership";
|
|
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;
|
|
// undo is the exact write that puts a permission back as it was before; absent
|
|
// when that state is unknown, so the entry offers no revert (a guess would turn
|
|
// a removed deny into a grant). Group entries invert their action instead.
|
|
undo?: { action: "set" | "unset"; value?: boolean };
|
|
status: "success" | "error";
|
|
output: string;
|
|
reverted?: boolean;
|
|
}
|
|
|
|
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]);
|
|
// The poll is what switches this page over once a wake lands, and back to
|
|
// NotRunning when the server stops (idle stop, the console, a player's timeout).
|
|
usePolling(reload, data && shownPhase(data) === "Running" ? STATUS_POLL_SLOW_MS : STATUS_POLL_FAST_MS);
|
|
|
|
// Ownership verification
|
|
const {
|
|
data: mine,
|
|
error: mineError,
|
|
reload: reloadMine,
|
|
} = useAsync(
|
|
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
|
|
[isAdmin, name]
|
|
);
|
|
|
|
// Online Players, read once the server is running: a page opened on a sleeping
|
|
// server fills the list when the poll sees it come up.
|
|
const running = data?.phase === "Running";
|
|
const { data: playersData, loading: playersLoading } = useAsync(
|
|
() => (running ? api.accessPlayers(name) : Promise.resolve(null)),
|
|
[name, running]
|
|
);
|
|
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, error: lpError, loading: lpLoading, reload: reloadLp } = useAsync(
|
|
() => selectedPlayer ? api.accessLuckPermsInfo(name, selectedPlayer) : Promise.resolve(null),
|
|
[name, selectedPlayer]
|
|
);
|
|
|
|
// LuckPerms does not reply over RCON (its commands finish asynchronously, after
|
|
// the response is flushed) — verified live on 5.5.85: every `lp` command
|
|
// returns an empty body while `list`/`plugins` answer normally. A silent,
|
|
// entry-less reply therefore means "could not read", NOT "no groups / no
|
|
// permissions": the two projection panels must not claim the latter on their
|
|
// own. The writes still work, so the page keeps offering them.
|
|
const lpSilent =
|
|
!!lpInfo &&
|
|
!(lpInfo.output ?? "").trim() &&
|
|
(lpInfo.groups?.length ?? 0) === 0 &&
|
|
(lpInfo.permissions?.length ?? 0) === 0;
|
|
// Unread: the lists below know nothing, so they say so instead of "none", and a
|
|
// group or node is removed by typing it — a read row is the only other way in,
|
|
// and on LuckPerms 5.5 there never is one.
|
|
const lpUnread = lpSilent || !!lpError;
|
|
|
|
// 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 || t("luckperms_no_output"),
|
|
});
|
|
reloadLp();
|
|
} catch (err) {
|
|
setFormFeedback({ kind: "err", msg: humanizeError(err) });
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Returns whether the server took the removal, so the typed form can clear itself.
|
|
const handleRemoveGroup = async (groupName: string): Promise<boolean> => {
|
|
if (!selectedPlayer || submitting) return false;
|
|
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 || t("luckperms_no_output"),
|
|
});
|
|
reloadLp();
|
|
return true;
|
|
} catch (err) {
|
|
setFormFeedback({ kind: "err", msg: humanizeError(err) });
|
|
return false;
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// typedPermission is the node and world the form holds, or null (with the reason
|
|
// shown) when either is malformed.
|
|
const typedPermission = (): { node: string; world: string } | null => {
|
|
const node = permNodeInput.trim();
|
|
const world = permWorldInput.trim();
|
|
if (!LP_NODE.test(node)) {
|
|
setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_node") });
|
|
return null;
|
|
}
|
|
if (world !== "" && !LP_CTX.test(world)) {
|
|
setFormFeedback({ kind: "err", msg: t("luckperms_error_invalid_world") });
|
|
return null;
|
|
}
|
|
return { node, world };
|
|
};
|
|
|
|
// priorValue is the value the last read showed for a node in a world (LuckPerms
|
|
// matches both case-insensitively), or undefined when no row showed one: the
|
|
// node was unset, or nothing could be read. A read still in flight or one whose
|
|
// refresh failed is stale (it predates the last write), so it knows nothing.
|
|
const priorValue = (node: string, world: string): boolean | undefined => {
|
|
if (lpUnread || lpLoading) return undefined;
|
|
return lpInfo?.permissions?.find(
|
|
(p) => p.node.toLowerCase() === node.toLowerCase() && (p.world ?? "").toLowerCase() === world.toLowerCase(),
|
|
)?.value;
|
|
};
|
|
|
|
const handleAddPermission = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedPlayer || !permNodeInput.trim() || submitting) return;
|
|
const typed = typedPermission();
|
|
if (!typed) return;
|
|
const { node, world } = typed;
|
|
// Reverting a set restores the value the node had; with none read, it removes
|
|
// the node, which undoes the set whenever the node was not there before.
|
|
const prior = priorValue(node, world);
|
|
|
|
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,
|
|
undo: prior === undefined ? { action: "unset" } : { action: "set", value: prior },
|
|
status: "success",
|
|
output: res.output || t("luckperms_no_output"),
|
|
});
|
|
setPermNodeInput("");
|
|
setPermWorldInput("");
|
|
reloadLp();
|
|
} catch (err) {
|
|
setFormFeedback({ kind: "err", msg: humanizeError(err) });
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Returns whether the server took the unset, so the typed form can clear itself.
|
|
// value is what the node held, when known; only then can the removal be reverted.
|
|
const handleRemovePermission = async (node: string, world?: string, value?: boolean): Promise<boolean> => {
|
|
if (!selectedPlayer || submitting) return false;
|
|
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,
|
|
value,
|
|
world: world || undefined,
|
|
undo: value === undefined ? undefined : { action: "set", value },
|
|
status: "success",
|
|
output: res.output || t("luckperms_no_output"),
|
|
});
|
|
reloadLp();
|
|
return true;
|
|
} catch (err) {
|
|
setFormFeedback({ kind: "err", msg: humanizeError(err) });
|
|
return false;
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// The typed removal: the node and world as the form holds them, whatever value
|
|
// the node was set to (LuckPerms unsets a node in a context regardless of it).
|
|
const handleRemoveTypedPermission = async () => {
|
|
if (!selectedPlayer || !permNodeInput.trim() || submitting) return;
|
|
const typed = typedPermission();
|
|
if (!typed) return;
|
|
if (await handleRemovePermission(typed.node, typed.world, priorValue(typed.node, typed.world))) {
|
|
setPermNodeInput("");
|
|
setPermWorldInput("");
|
|
}
|
|
};
|
|
|
|
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 undo = item.undo;
|
|
if (!undo) return;
|
|
await api.accessPermission(
|
|
name,
|
|
undo.action,
|
|
item.player,
|
|
item.target,
|
|
undo.action === "set" ? undo.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 = (
|
|
<BackLink to={`/servers/${name}`} label={t("luckperms_back_to_console")} />
|
|
);
|
|
|
|
if (loading && !data) {
|
|
return (
|
|
<>
|
|
{back}
|
|
<Loading />
|
|
</>
|
|
);
|
|
}
|
|
if (error && !data) {
|
|
return (
|
|
<>
|
|
{back}
|
|
<ErrorState error={error} onRetry={reload} />
|
|
</>
|
|
);
|
|
}
|
|
if (!data) return back;
|
|
|
|
const pending = ownershipPending(tierLoading, isAdmin, mine, mineError);
|
|
const owned = canManage(isAdmin, mine, name);
|
|
const phase = shownPhase(data);
|
|
const failure = startFailure(data);
|
|
|
|
const header = (
|
|
<PageHeader
|
|
icon={Shield}
|
|
title={data.displayName || data.name}
|
|
subtitle={t("luckperms_desc")}
|
|
actions={<PhaseBadge phase={phase} failure={failure} autoRestarts={data.autoRestarts} />}
|
|
className="mb-6"
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{back}
|
|
{header}
|
|
{error && <RefreshError error={error} className="mb-4" />}
|
|
{pending ? (
|
|
<Loading />
|
|
) : mineError ? (
|
|
<ErrorState error={mineError} onRetry={reloadMine} />
|
|
) : !owned ? (
|
|
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
|
|
) : phase !== "Running" ? (
|
|
<NotRunning
|
|
title={t("players_not_running_title")}
|
|
body={t("players_not_running_body")}
|
|
serverName={name}
|
|
phase={data.phase}
|
|
desiredState={data.desiredState}
|
|
failure={failure}
|
|
autoRestarts={data.autoRestarts}
|
|
onWoken={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"
|
|
aria-label={t("luckperms_player_lookup")}
|
|
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>
|
|
|
|
{/* Ground truth: the raw RCON reply behind the two projection panels.
|
|
The parse is LuckPerms-format-specific — on a server without the
|
|
plugin it yields zero entries, and without this disclosure the
|
|
panels would present that as "no groups / no permissions" while
|
|
the real reply ("Unknown or incomplete command…") stays invisible.
|
|
Same disclosure the whitelist/ban/online rosters carry. */}
|
|
{lpInfo?.output?.trim() && (
|
|
<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>
|
|
{t("luckperms_rcon_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">
|
|
{lpInfo.output}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
|
|
{/* A read that failed (no LuckPerms on the server, console down) says
|
|
why here; the panels below would otherwise sit empty with no cause. */}
|
|
{lpError && (
|
|
<div role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
|
|
{humanizeError(lpError)}
|
|
</div>
|
|
)}
|
|
|
|
{lpSilent && (
|
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
|
{t("luckperms_no_reply")}
|
|
</div>
|
|
)}
|
|
|
|
{/* Operations Feedback */}
|
|
{formFeedback && (
|
|
<div role={formFeedback.kind === "ok" ? "status" : "alert"} 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 && !lpInfo ? (
|
|
<div className="flex justify-center py-4">
|
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
</div>
|
|
) : (
|
|
<div className={cn("space-y-4 transition-opacity", lpLoading && "opacity-60 pointer-events-none")}>
|
|
<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}
|
|
aria-label={t("luckperms_remove_group", { group })}
|
|
title={t("luckperms_remove_group", { group })}
|
|
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>
|
|
))
|
|
) : lpUnread ? (
|
|
<p className="text-xs text-muted-foreground py-1">{t("luckperms_groups_unread")}</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground/60 italic py-1">{t("luckperms_no_parent_groups")}</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 htmlFor="lp-group-name" className="text-xs text-muted-foreground font-semibold">
|
|
{t("luckperms_group_name")}
|
|
</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="lp-group-name"
|
|
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" aria-label={t("luckperms_presets")}>
|
|
<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>
|
|
<div className="flex gap-2 w-full sm:w-auto">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
const val = groupNameInput.trim();
|
|
if (val) {
|
|
handleAddGroup(val);
|
|
setGroupNameInput("");
|
|
}
|
|
}}
|
|
disabled={submitting || !groupNameInput.trim()}
|
|
className="h-9 px-4 shrink-0 flex-1 sm:flex-none hover:bg-primary/90 transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{t("luckperms_add_group_btn")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={async () => {
|
|
const val = groupNameInput.trim();
|
|
if (val && (await handleRemoveGroup(val))) {
|
|
setGroupNameInput("");
|
|
}
|
|
}}
|
|
disabled={submitting || !groupNameInput.trim()}
|
|
className="h-9 px-4 shrink-0 flex-1 sm:flex-none hover:text-destructive transition-colors"
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" />
|
|
{t("luckperms_remove_group_btn")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</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 && !lpInfo ? (
|
|
<div className="flex justify-center py-4">
|
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
</div>
|
|
) : (
|
|
<div className={cn("space-y-4 transition-opacity", lpLoading && "opacity-60 pointer-events-none")}>
|
|
<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">{t("luckperms_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, p.value)}
|
|
aria-label={t("luckperms_remove_perm", { node: p.node })}
|
|
title={t("luckperms_remove_perm", { node: p.node })}
|
|
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>
|
|
{lpUnread ? (
|
|
<td colSpan={4} className="px-4 py-10 text-center text-muted-foreground">
|
|
{t("luckperms_perms_unread")}
|
|
</td>
|
|
) : (
|
|
<td colSpan={4} className="px-4 py-10 text-center text-muted-foreground/60 italic">
|
|
{t("luckperms_no_perms")}
|
|
</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_perm_form_title")}
|
|
</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" aria-label={t("luckperms_presets")}>
|
|
<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 flex-col sm:flex-row sm:items-center gap-3 pt-1">
|
|
<p className="text-[11px] text-muted-foreground sm:flex-1">
|
|
{t("luckperms_remove_perm_hint")}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="submit"
|
|
disabled={submitting || !permNodeInput.trim()}
|
|
className="flex-1 sm:flex-none h-9 hover:bg-primary/90 transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{t("luckperms_add_perm_btn")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleRemoveTypedPermission}
|
|
disabled={submitting || !permNodeInput.trim()}
|
|
className="flex-1 sm:flex-none h-9 hover:text-destructive transition-colors"
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" />
|
|
{t("luckperms_remove_perm_btn")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</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"
|
|
aria-label={t("luckperms_clear_history")}
|
|
title={t("luckperms_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 shownValue = h.value === undefined ? "" : ` (${h.value ? "TRUE" : "FALSE"})`;
|
|
const actionLabel = h.type === "group"
|
|
? `${h.action === "add" ? "+" : "-"} ${t("luckperms_group_name")}: ${h.target}`
|
|
: `${h.action === "set" ? "+" : "-"} ${h.target}${shownValue}${h.world ? ` [${h.world}]` : ""}`;
|
|
const revertible = h.type === "group" || !!h.undo;
|
|
const revertTitle = h.undo
|
|
? h.undo.action === "set"
|
|
? t("luckperms_revert_to_set", { node: h.target, value: h.undo.value ? "TRUE" : "FALSE" })
|
|
: t("luckperms_revert_to_unset", { node: h.target })
|
|
: undefined;
|
|
|
|
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 && revertible && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={submitting}
|
|
onClick={() => handleRevert(h)}
|
|
title={revertTitle}
|
|
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>
|
|
)}
|
|
{isSuccess && !h.reverted && !revertible && (
|
|
<span className="text-[10px] text-muted-foreground select-none">
|
|
{t("luckperms_revert_unknown")}
|
|
</span>
|
|
)}
|
|
{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>
|
|
{t("luckperms_rcon_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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|