Security page, admin quick controls, and whitelist reload self-healing

- RCON client retries once on a fresh connection after stale-socket
  timeouts (panel idle overnight held dead sockets, so 'whitelist
  reload' silently failed and approvals needed a server restart)
- Auth watcher verifies pending players against the LIVE whitelist every
  5s and re-writes/reloads if the server missed it, refunding lost
  window time
- Log watcher records blocked join attempts (name + IP) with rate-
  limited notifications — scanner bots are now visible in the panel
- New Security page: blocked-attempt log with one-click IP ban, banned
  players/IPs management, kick-everyone lockdown
- Player detail modal: ban, op/deop, gamemode switching
- Overview quick controls: time/weather/difficulty, gamerule toggles
  (keepInventory, mobGriefing, PvP...), broadcast box
- Settings toggles for blocked-attempt notifications (panel + Discord)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Fable 5 committed 2026-07-11 11:50:27 -05:00
1 parent 4fe304c5af
commit deeeaf0042
13 files changed
+809 -3

No files matched your search

+65 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Users, UserX, Wifi } from "lucide-react";
import { Users, UserX, Wifi, Crown, Gavel } from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, Badge, Button, EmptyState, Modal } from "@/components/ui";
import { timeAgo, fmtDateTime } from "@/lib/format";
@@ -31,6 +31,33 @@ export default function PlayersPage() {
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
const [detail, setDetail] = useState<PlayerRow | null>(null);
const [kicking, setKicking] = useState<string | null>(null);
const [adminBusy, setAdminBusy] = useState<string | null>(null);
const adminAction = async (label: string, body: Record<string, unknown>, message: string) => {
setAdminBusy(label);
try {
await api("/api/admin", { body });
toast(message, "success");
} catch (err) {
toast((err as Error).message, "error");
} finally {
setAdminBusy(null);
}
};
const banPlayer = async (name: string) => {
setAdminBusy("ban");
try {
await api("/api/security", { body: { op: "ban-player", name, reason: "Banned by the admin" } });
toast(`${name} banned`, "warn");
setDetail(null);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setAdminBusy(null);
}
};
useSSE("/api/stream/events", {
"mc-event": () => {
@@ -193,6 +220,43 @@ export default function PlayersPage() {
<div>UUID: <code>{detail.uuid}</code></div>
<div>Last seen: {detail.isOnline ? "online now" : fmtDateTime(detail.lastSeenAt)}</div>
</div>
{/* admin actions */}
<div className="mt-4 flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
{detail.isOnline && (
<>
{(["survival", "creative", "spectator"] as const).map((mode) => (
<Button
key={mode}
size="sm"
busy={adminBusy === `gm-${mode}`}
onClick={() => void adminAction(`gm-${mode}`, { op: "gamemode", name: detail.name, mode }, `${detail.name} → ${mode}`)}
>
{mode}
</Button>
))}
<span className="mx-1 h-4 w-px bg-border" />
</>
)}
<Button
size="sm"
busy={adminBusy === "op"}
onClick={() => void adminAction("op", { op: "op", name: detail.name, grant: true }, `${detail.name} is now an operator`)}
>
<Crown size={12} /> Op
</Button>
<Button
size="sm"
busy={adminBusy === "deop"}
onClick={() => void adminAction("deop", { op: "op", name: detail.name, grant: false }, `${detail.name} de-opped`)}
>
De-op
</Button>
<span className="mx-1 h-4 w-px bg-border" />
<Button size="sm" variant="danger" busy={adminBusy === "ban"} onClick={() => void banPlayer(detail.name)}>
<Gavel size={12} /> Ban
</Button>
</div>
</div>
)}
</Modal>