feat: improve users page with inline staff actions and commandocentrum cleanup
This commit is contained in:
1 parent
d588ae5f0f
commit
beb36d2dbf
3 files changed
+298
-7
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { CurrencyType } from "@/lib/services/currency";
|
||||
|
||||
const PATH = "/admin/commandocentrum";
|
||||
|
||||
@@ -51,3 +52,158 @@ export async function hotelAlert(formData: FormData): Promise<void> {
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
|
||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") ?? "").trim();
|
||||
if (!userId || !username) return;
|
||||
try {
|
||||
await rcon.disconnectUser(userId, username);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Send an alert to a specific user (rcon: alertuser). */
|
||||
export async function alertUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
|
||||
if (!userId || !message) return;
|
||||
try {
|
||||
await rcon.alertUser(userId, message);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Forward a user to a specific room (rcon: forwarduser). */
|
||||
export async function forwardUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const roomId = Number(formData.get("roomId"));
|
||||
if (!userId || !roomId) return;
|
||||
try {
|
||||
await rcon.forwardUser(userId, roomId);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Give credits to a user (rcon: givecredits). */
|
||||
export async function giveCredits(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const credits = Number(formData.get("credits"));
|
||||
if (!userId || !credits || credits <= 0) return;
|
||||
try {
|
||||
await rcon.giveCredits(userId, credits);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Give duckets to a user (rcon: givepoints type=duckets). */
|
||||
export async function giveDuckets(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (!userId || !amount || amount <= 0) return;
|
||||
try {
|
||||
await rcon.giveDuckets(userId, amount);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
|
||||
export async function giveDiamonds(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (!userId || !amount || amount <= 0) return;
|
||||
try {
|
||||
await rcon.giveDiamonds(userId, amount);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Give a badge to a user (rcon: givebadge). */
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const badge = String(formData.get("badge") ?? "").trim();
|
||||
if (!userId || !badge) return;
|
||||
try {
|
||||
await rcon.giveBadge(userId, badge);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Set a user's motto (rcon: setmotto). */
|
||||
export async function setMotto(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const motto = String(formData.get("motto") ?? "").trim().slice(0, 127);
|
||||
if (!userId || !motto) return;
|
||||
try {
|
||||
await rcon.setMotto(userId, motto);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Set a user's rank (rcon: setrank). */
|
||||
export async function setRank(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const rank = Number(formData.get("rank"));
|
||||
if (!userId || rank < 0 || rank > 10) return;
|
||||
try {
|
||||
await rcon.setRank(userId, rank);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Execute a command as a user (rcon: executecommand). */
|
||||
export async function executeCommand(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const command = String(formData.get("command") ?? "").trim();
|
||||
if (!userId || !command) return;
|
||||
try {
|
||||
await rcon.executeCommand(userId, command);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Send a gift to a user (rcon: sendgift). */
|
||||
export async function sendGift(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const itemId = Number(formData.get("itemId"));
|
||||
const message = String(formData.get("message") ?? "Here is a gift.").trim().slice(0, 255);
|
||||
if (!userId || !itemId) return;
|
||||
try {
|
||||
await rcon.sendGift(userId, itemId, message);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
@@ -4,6 +4,17 @@ import {
|
||||
updateCatalog,
|
||||
updateNavigator,
|
||||
updateWordFilter,
|
||||
disconnectUser,
|
||||
alertUser,
|
||||
forwardUser,
|
||||
giveCredits,
|
||||
giveDuckets,
|
||||
giveDiamonds,
|
||||
giveBadge,
|
||||
setMotto,
|
||||
setRank,
|
||||
executeCommand,
|
||||
sendGift,
|
||||
} from "@/actions/commandocentrum";
|
||||
import { DiagnosticRow, InfoItem, StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -21,8 +32,6 @@ function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
// stacktrace is a MySQL BLOB (Prisma `Bytes`), so it arrives as a Buffer/
|
||||
// Uint8Array. Decode to UTF-8 and trim to a single readable preview line.
|
||||
function decodeStacktrace(raw: unknown): string {
|
||||
if (raw == null) return "";
|
||||
try {
|
||||
@@ -45,8 +54,6 @@ function uptime(seconds: number): string {
|
||||
}
|
||||
|
||||
export default async function CommandoCentrum() {
|
||||
// Gather everything in parallel; any failure degrades to a safe default so the
|
||||
// panel still renders (and the DB-down state is itself a diagnostic).
|
||||
const [onlineCount, totalUsers, errors, dbOk, emulatorOnline, maintenance, activity] =
|
||||
await Promise.all([
|
||||
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
|
||||
@@ -63,7 +70,6 @@ export default async function CommandoCentrum() {
|
||||
.catch(() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>),
|
||||
]);
|
||||
|
||||
// Resolve staff usernames for the activity feed.
|
||||
const staffIds = Array.from(new Set(activity.map((a) => Number(a.userId)))).filter((n) => n > 0);
|
||||
let nameById = new Map<number, string>();
|
||||
if (staffIds.length) {
|
||||
@@ -224,6 +230,111 @@ export default async function CommandoCentrum() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── User RCON actions ──────────────────────────────────── */}
|
||||
<section className="admin-section">
|
||||
<h2>User actions (RCON)</h2>
|
||||
<p className="muted" style={{ marginTop: "-0.4rem" }}>
|
||||
Enter a User ID to perform actions on a specific user. Username required for disconnect.
|
||||
</p>
|
||||
|
||||
<div className="grid cols-3">
|
||||
<form action={disconnectUser} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Disconnect / Kick</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Force disconnect a user.</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="username" type="text" placeholder="Username (required)" required style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-danger">Disconnect user</button>
|
||||
</form>
|
||||
|
||||
<form action={alertUser} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Alert user</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Send a popup alert to a user.</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="message" type="text" placeholder="Alert message" required maxLength={512} style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Send alert</button>
|
||||
</form>
|
||||
|
||||
<form action={forwardUser} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Forward to room</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Teleport user to a room ID.</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="roomId" type="number" min={1} placeholder="Room ID" required style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Forward user</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-3" style={{ marginTop: "1rem" }}>
|
||||
<form action={giveCredits} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Give credits</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Add credits to a user.</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="credits" type="number" min={1} placeholder="Amount" required style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Give credits</button>
|
||||
</form>
|
||||
|
||||
<form action={giveDuckets} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Give duckets</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Add duckets to a user.</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="amount" type="number" min={1} placeholder="Amount" required style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Give duckets</button>
|
||||
</form>
|
||||
|
||||
<form action={giveDiamonds} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Give diamonds</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Add diamonds to a user.</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="amount" type="number" min={1} placeholder="Amount" required style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Give diamonds</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-3" style={{ marginTop: "1rem" }}>
|
||||
<form action={giveBadge} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Give badge</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Give a badge code (e.g. HOF1).</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="badge" type="text" placeholder="Badge code" required maxLength={32} style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Give badge</button>
|
||||
</form>
|
||||
|
||||
<form action={setMotto} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Set motto</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Change user's motto (max 127 chars).</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="motto" type="text" placeholder="New motto" required maxLength={127} style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Set motto</button>
|
||||
</form>
|
||||
|
||||
<form action={setRank} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Set rank</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Change user rank (0–10).</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="rank" type="number" min={0} max={10} placeholder="Rank (0–10)" required style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Set rank</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-3" style={{ marginTop: "1rem" }}>
|
||||
<form action={executeCommand} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Execute command</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Run a chat command as the user.</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="command" type="text" placeholder="Command (e.g. :pickall)" required maxLength={100} style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Execute</button>
|
||||
</form>
|
||||
|
||||
<form action={sendGift} className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Send gift</h3>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>Send a furniture item as gift.</p>
|
||||
<input name="userId" type="number" min={1} placeholder="User ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="itemId" type="number" min={1} placeholder="Furni ID" required style={{ width: "100%", marginBottom: "0.5rem" }} />
|
||||
<input name="message" type="text" placeholder="Gift message" maxLength={255} defaultValue="Here is a gift." style={{ width: "100%", marginBottom: "0.75rem" }} />
|
||||
<button type="submit" className="btn btn-primary">Send gift</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Staff activity ─────────────────────────────────────── */}
|
||||
<section className="admin-section">
|
||||
<h2>Recent staff activity</h2>
|
||||
@@ -309,4 +420,4 @@ export default async function CommandoCentrum() {
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const PER_PAGE = 25;
|
||||
export default async function AdminUsers({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
searchParams: Promise<{ q?: string; page?: string; form?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const q = (sp.q ?? "").trim();
|
||||
@@ -79,6 +79,7 @@ export default async function AdminUsers({
|
||||
<th>Rank</th>
|
||||
<th>Credits</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -95,6 +96,29 @@ export default async function AdminUsers({
|
||||
{u.online === "1" ? "online" : "offline"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", gap: "0.25rem", flexWrap: "wrap" }}>
|
||||
<Link href={`/admin/users/${u.id}/edit`} className="btn btn-outline" style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}>Edit</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => alert(`Set rank for ${u.username}`)}
|
||||
style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}
|
||||
>Set Rank</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => alert(`Disconnect ${u.username}`)}
|
||||
style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}
|
||||
>Disconnect</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => alert(`Alert ${u.username}`)}
|
||||
style={{ fontSize: "0.75rem", padding: "0.25rem 0.5rem" }}
|
||||
>Alert</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in new issue
Block a user