Gate translations, RCON, and user mutations on SETTINGS_EDIT, RCON_EXECUTE, and USERS_EDIT instead of dashboard/rank checks; redirect the legacy user-edit URL to the guarded canonical page. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
bae543baf6
commit
f2427b3483
8 files changed
+127
-284
No files matched your search
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -28,8 +29,8 @@ function toInt(value: FormDataEntryValue | null, min = 0): number | null {
|
|||||||
* user from the session and logs the action. emulator-owned users.id is Int.
|
* user from the session and logs the action. emulator-owned users.id is Int.
|
||||||
*/
|
*/
|
||||||
export async function updateUser(formData: FormData): Promise<void> {
|
export async function updateUser(formData: FormData): Promise<void> {
|
||||||
// Never trust the client: re-check staff inside the action.
|
// Never trust the client: re-check USERS_EDIT inside the action.
|
||||||
const staff = await requireStaff();
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
|
|
||||||
const userId = Number(formData.get("id"));
|
const userId = Number(formData.get("id"));
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||||
@@ -95,6 +96,6 @@ export async function updateUser(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/admin/users/${userId}`);
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
revalidatePath(`/admin/users/${userId}/edit`);
|
revalidatePath(`/admin/users/edit/${userId}`);
|
||||||
redirect(`/admin/users/${userId}`);
|
redirect(`/admin/users/show/${userId}`);
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
const PATH = "/admin/commandocentrum";
|
const PATH = "/admin/commandocentrum";
|
||||||
|
|
||||||
|
async function requireRcon(): Promise<void> {
|
||||||
|
await requirePermission(PERMS.RCON_EXECUTE);
|
||||||
|
}
|
||||||
|
|
||||||
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
||||||
export async function updateCatalog(): Promise<void> {
|
export async function updateCatalog(): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
try {
|
try {
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -19,7 +24,7 @@ export async function updateCatalog(): Promise<void> {
|
|||||||
|
|
||||||
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
||||||
export async function updateWordFilter(): Promise<void> {
|
export async function updateWordFilter(): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
try {
|
try {
|
||||||
await rcon.updateWordFilter();
|
await rcon.updateWordFilter();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -30,7 +35,7 @@ export async function updateWordFilter(): Promise<void> {
|
|||||||
|
|
||||||
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
||||||
export async function updateNavigator(): Promise<void> {
|
export async function updateNavigator(): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
try {
|
try {
|
||||||
await rcon.send("updatenavigator", null);
|
await rcon.send("updatenavigator", null);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -41,7 +46,7 @@ export async function updateNavigator(): Promise<void> {
|
|||||||
|
|
||||||
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
||||||
export async function hotelAlert(formData: FormData): Promise<void> {
|
export async function hotelAlert(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const message = String(formData.get("message") ?? "")
|
const message = String(formData.get("message") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -57,7 +62,7 @@ export async function hotelAlert(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
|
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
|
||||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
export async function disconnectUser(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const username = String(formData.get("username") ?? "")
|
const username = String(formData.get("username") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -73,7 +78,7 @@ export async function disconnectUser(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Send an alert to a specific user (rcon: alertuser). */
|
/** Send an alert to a specific user (rcon: alertuser). */
|
||||||
export async function alertUser(formData: FormData): Promise<void> {
|
export async function alertUser(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const message = String(formData.get("message") ?? "")
|
const message = String(formData.get("message") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -90,7 +95,7 @@ export async function alertUser(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Forward a user to a specific room (rcon: forwarduser). */
|
/** Forward a user to a specific room (rcon: forwarduser). */
|
||||||
export async function forwardUser(formData: FormData): Promise<void> {
|
export async function forwardUser(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const roomId = Number(formData.get("roomId"));
|
const roomId = Number(formData.get("roomId"));
|
||||||
if (!userId || !roomId) return;
|
if (!userId || !roomId) return;
|
||||||
@@ -104,7 +109,7 @@ export async function forwardUser(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Give credits to a user (rcon: givecredits). */
|
/** Give credits to a user (rcon: givecredits). */
|
||||||
export async function giveCredits(formData: FormData): Promise<void> {
|
export async function giveCredits(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const credits = Number(formData.get("credits"));
|
const credits = Number(formData.get("credits"));
|
||||||
if (!userId || !credits || credits <= 0) return;
|
if (!userId || !credits || credits <= 0) return;
|
||||||
@@ -118,7 +123,7 @@ export async function giveCredits(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Give duckets to a user (rcon: givepoints type=duckets). */
|
/** Give duckets to a user (rcon: givepoints type=duckets). */
|
||||||
export async function giveDuckets(formData: FormData): Promise<void> {
|
export async function giveDuckets(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const amount = Number(formData.get("amount"));
|
const amount = Number(formData.get("amount"));
|
||||||
if (!userId || !amount || amount <= 0) return;
|
if (!userId || !amount || amount <= 0) return;
|
||||||
@@ -132,7 +137,7 @@ export async function giveDuckets(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
|
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
|
||||||
export async function giveDiamonds(formData: FormData): Promise<void> {
|
export async function giveDiamonds(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const amount = Number(formData.get("amount"));
|
const amount = Number(formData.get("amount"));
|
||||||
if (!userId || !amount || amount <= 0) return;
|
if (!userId || !amount || amount <= 0) return;
|
||||||
@@ -146,7 +151,7 @@ export async function giveDiamonds(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Give a badge to a user (rcon: givebadge). */
|
/** Give a badge to a user (rcon: givebadge). */
|
||||||
export async function giveBadge(formData: FormData): Promise<void> {
|
export async function giveBadge(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const badge = String(formData.get("badge") ?? "")
|
const badge = String(formData.get("badge") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -162,7 +167,7 @@ export async function giveBadge(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Set a user's motto (rcon: setmotto). */
|
/** Set a user's motto (rcon: setmotto). */
|
||||||
export async function setMotto(formData: FormData): Promise<void> {
|
export async function setMotto(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const motto = String(formData.get("motto") ?? "")
|
const motto = String(formData.get("motto") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -179,7 +184,7 @@ export async function setMotto(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Set a user's rank (rcon: setrank). */
|
/** Set a user's rank (rcon: setrank). */
|
||||||
export async function setRank(formData: FormData): Promise<void> {
|
export async function setRank(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const rank = Number(formData.get("rank"));
|
const rank = Number(formData.get("rank"));
|
||||||
if (!userId || rank < 0 || rank > 10) return;
|
if (!userId || rank < 0 || rank > 10) return;
|
||||||
@@ -193,7 +198,7 @@ export async function setRank(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Execute a command as a user (rcon: executecommand). */
|
/** Execute a command as a user (rcon: executecommand). */
|
||||||
export async function executeCommand(formData: FormData): Promise<void> {
|
export async function executeCommand(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const command = String(formData.get("command") ?? "")
|
const command = String(formData.get("command") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -209,7 +214,7 @@ export async function executeCommand(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Send a gift to a user (rcon: sendgift). */
|
/** Send a gift to a user (rcon: sendgift). */
|
||||||
export async function sendGift(formData: FormData): Promise<void> {
|
export async function sendGift(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireRcon();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const itemId = Number(formData.get("itemId"));
|
const itemId = Number(formData.get("itemId"));
|
||||||
const message = String(formData.get("message") ?? "Here is a gift.")
|
const message = String(formData.get("message") ?? "Here is a gift.")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getClientTranslationFile,
|
getClientTranslationFile,
|
||||||
} from "@/lib/client-translation-files";
|
} from "@/lib/client-translation-files";
|
||||||
import { patchJson5 } from "@/lib/json5-patch";
|
import { patchJson5 } from "@/lib/json5-patch";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
@@ -41,10 +42,8 @@ const saveTranslationsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const saveTranslations = adminAction(
|
export const saveTranslations = adminAction(
|
||||||
{ schema: saveTranslationsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
|
||||||
|
|
||||||
const filePath = path.join(
|
const filePath = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
"messages",
|
"messages",
|
||||||
@@ -68,10 +67,8 @@ const saveClientTranslationsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const saveClientTranslations = adminAction(
|
export const saveClientTranslations = adminAction(
|
||||||
{ schema: saveClientTranslationsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
|
||||||
|
|
||||||
const file = getClientTranslationFile(ctx.data.fileId);
|
const file = getClientTranslationFile(ctx.data.fileId);
|
||||||
if (!file) throw new ActionError("Unknown file");
|
if (!file) throw new ActionError("Unknown file");
|
||||||
if (file.readOnly) throw new ActionError("File is read-only");
|
if (file.readOnly) throw new ActionError("File is read-only");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { Activity } from "lucide-react";
|
import { Activity } from "lucide-react";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import {
|
||||||
alertUser,
|
alertUser,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
OnlineUsersWidget,
|
OnlineUsersWidget,
|
||||||
StatusCard,
|
StatusCard,
|
||||||
} from "@/components/admin/dashboard";
|
} from "@/components/admin/dashboard";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -61,6 +63,11 @@ function uptime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function CommandoCentrum() {
|
export default async function CommandoCentrum() {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.RCON_EXECUTE, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
|
||||||
const t = await getTranslations("pages.admin.commandocentrum");
|
const t = await getTranslations("pages.admin.commandocentrum");
|
||||||
|
|
||||||
const [
|
const [
|
||||||
|
|||||||
@@ -1,197 +1,16 @@
|
|||||||
import { User } from "lucide-react";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { updateUser } from "@/actions/admin-user-edit";
|
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// users_currency.type values for the non-credits currencies (see send-currency.ts).
|
/**
|
||||||
const DUCKETS_TYPE = 0;
|
* Legacy URL — the canonical edit UI lives at /admin/users/edit/[id]
|
||||||
const DIAMONDS_TYPE = 5;
|
* and is gated by PERMS.USERS_EDIT.
|
||||||
|
*/
|
||||||
export default async function AdminUserEdit({
|
export default async function AdminUserEditRedirect({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Gate the page with the SAME helper the action re-checks.
|
|
||||||
await requireStaff();
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const t = await getTranslations("pages.admin.users.editForm");
|
redirect(`/admin/users/edit/${id}`);
|
||||||
const userId = Number(id);
|
|
||||||
if (!Number.isInteger(userId) || userId <= 0) notFound();
|
|
||||||
|
|
||||||
let user: {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
mail: string | null;
|
|
||||||
motto: string;
|
|
||||||
look: string;
|
|
||||||
rank: number;
|
|
||||||
credits: number;
|
|
||||||
pixels: number;
|
|
||||||
points: number;
|
|
||||||
} | null = null;
|
|
||||||
let duckets = 0;
|
|
||||||
let diamonds = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
user = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
mail: true,
|
|
||||||
motto: true,
|
|
||||||
look: true,
|
|
||||||
rank: true,
|
|
||||||
credits: true,
|
|
||||||
pixels: true,
|
|
||||||
points: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (user) {
|
|
||||||
const currencies = await prisma.usersCurrency.findMany({
|
|
||||||
where: { userId, type: { in: [DUCKETS_TYPE, DIAMONDS_TYPE] } },
|
|
||||||
select: { type: true, amount: true },
|
|
||||||
});
|
|
||||||
for (const c of currencies) {
|
|
||||||
if (c.type === DUCKETS_TYPE) duckets = c.amount;
|
|
||||||
else if (c.type === DIAMONDS_TYPE) diamonds = c.amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<p className="text-sm theme-text-muted dark:theme-text-muted">
|
|
||||||
<Link href="/admin/users">← {t("title")}</Link>
|
|
||||||
</p>
|
|
||||||
<h1>{t("title")}</h1>
|
|
||||||
<p className="text-xs theme-text-muted dark:theme-text-muted">
|
|
||||||
{t("loadError")}
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) notFound();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<p className="text-sm theme-text-muted dark:theme-text-muted mb-2">
|
|
||||||
<Link href={`/admin/users/${user.id}`}>← {user.username}</Link>
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
|
||||||
<User size={20} className="text-[var(--admin-accent)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
|
||||||
{t("title", { username: user.username })}
|
|
||||||
</h1>
|
|
||||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
|
||||||
{t("idLabel", { id: user.id })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form action={updateUser} className="admin-card">
|
|
||||||
<input type="hidden" name="id" value={user.id} />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<label>
|
|
||||||
{t("email")}
|
|
||||||
<input
|
|
||||||
name="mail"
|
|
||||||
type="email"
|
|
||||||
defaultValue={user.mail ?? ""}
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
{t("rank")}
|
|
||||||
<input name="rank" type="number" min={1} defaultValue={user.rank} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
{t("motto")}
|
|
||||||
<input name="motto" defaultValue={user.motto} maxLength={127} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
{t("look")}
|
|
||||||
<input name="look" defaultValue={user.look} maxLength={256} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
{t("credits")}
|
|
||||||
<input
|
|
||||||
name="credits"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={user.credits}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
{t("pixels")}
|
|
||||||
<input
|
|
||||||
name="pixels"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={user.pixels}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
{t("points")}
|
|
||||||
<input
|
|
||||||
name="points"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={user.points}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
{t("duckets")}
|
|
||||||
<input
|
|
||||||
name="duckets"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={duckets}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
{t("diamonds")}
|
|
||||||
<input
|
|
||||||
name="diamonds"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={diamonds}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs theme-text-muted dark:theme-text-muted">
|
|
||||||
{t("hint")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
{t("save")}
|
|
||||||
</button>
|
|
||||||
<Link href={`/admin/users/${user.id}`} className="btn">
|
|
||||||
{t("cancel")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,41 +1,14 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import { logger } from "@/lib/logger";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
const giveCurrency = async ({
|
export const POST = withAdmin(
|
||||||
rconClient: _rconClient,
|
{ permission: PERMS.USERS_EDIT },
|
||||||
db: _db,
|
async (request, context) => {
|
||||||
userId,
|
const staffId = context.session.user.id;
|
||||||
type,
|
|
||||||
amount,
|
|
||||||
}: {
|
|
||||||
rconClient: typeof rcon;
|
|
||||||
db: typeof prisma;
|
|
||||||
userId: number;
|
|
||||||
type: string;
|
|
||||||
amount: number;
|
|
||||||
}) => {
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: 1,
|
|
||||||
action: "give_currency",
|
|
||||||
description: `Gave ${amount} ${type} to user #${userId}`,
|
|
||||||
targetType: "user",
|
|
||||||
targetId: userId,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Gave ${amount} ${type} to user #${userId}`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const staff = await requireStaff();
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const username = String(formData.get("username") || "");
|
const username = String(formData.get("username") || "");
|
||||||
@@ -60,7 +33,7 @@ export async function POST(request: Request) {
|
|||||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
||||||
await rcon.setRank(userId, rank);
|
await rcon.setRank(userId, rank);
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId,
|
||||||
action: "rank_change",
|
action: "rank_change",
|
||||||
description: `Set rank of user #${userId} to ${rank}`,
|
description: `Set rank of user #${userId} to ${rank}`,
|
||||||
targetType: "user",
|
targetType: "user",
|
||||||
@@ -75,6 +48,13 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (action === "disconnect") {
|
if (action === "disconnect") {
|
||||||
await rcon.disconnectUser(userId, username);
|
await rcon.disconnectUser(userId, username);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId,
|
||||||
|
action: "disconnect",
|
||||||
|
description: `Disconnected user #${userId} (${username})`,
|
||||||
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
@@ -95,6 +75,13 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await rcon.alertUser(userId, message);
|
await rcon.alertUser(userId, message);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId,
|
||||||
|
action: "alert_user",
|
||||||
|
description: `Sent alert to user #${userId}`,
|
||||||
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: true, message: `Sent alert to user #${userId}` },
|
{ success: true, message: `Sent alert to user #${userId}` },
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
@@ -109,12 +96,13 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await giveCurrency({
|
await rcon.giveCredits(userId, credits);
|
||||||
rconClient: rcon,
|
await logStaffActivity({
|
||||||
db: prisma,
|
staffId,
|
||||||
userId,
|
action: "give_currency",
|
||||||
type: "credits",
|
description: `Gave ${credits} credits to user #${userId}`,
|
||||||
amount: credits,
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -133,12 +121,13 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await giveCurrency({
|
await rcon.giveDuckets(userId, amount);
|
||||||
rconClient: rcon,
|
await logStaffActivity({
|
||||||
db: prisma,
|
staffId,
|
||||||
userId,
|
action: "give_currency",
|
||||||
type: "duckets",
|
description: `Gave ${amount} duckets to user #${userId}`,
|
||||||
amount,
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: true, message: `Gave ${amount} duckets to user #${userId}` },
|
{ success: true, message: `Gave ${amount} duckets to user #${userId}` },
|
||||||
@@ -154,12 +143,13 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await giveCurrency({
|
await rcon.giveDiamonds(userId, amount);
|
||||||
rconClient: rcon,
|
await logStaffActivity({
|
||||||
db: prisma,
|
staffId,
|
||||||
userId,
|
action: "give_currency",
|
||||||
type: "diamonds",
|
description: `Gave ${amount} diamonds to user #${userId}`,
|
||||||
amount,
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -178,12 +168,13 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await giveCurrency({
|
await rcon.givePointsGotw(userId, amount);
|
||||||
rconClient: rcon,
|
await logStaffActivity({
|
||||||
db: prisma,
|
staffId,
|
||||||
userId,
|
action: "give_currency",
|
||||||
type: "points",
|
description: `Gave ${amount} points to user #${userId}`,
|
||||||
amount,
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: true, message: `Gave ${amount} points to user #${userId}` },
|
{ success: true, message: `Gave ${amount} points to user #${userId}` },
|
||||||
@@ -195,14 +186,5 @@ export async function POST(request: Request) {
|
|||||||
{ success: false, message: `Unknown action: ${action}` },
|
{ success: false, message: `Unknown action: ${action}` },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
},
|
||||||
logger.error("Admin users actions error", {
|
);
|
||||||
module: "admin/users/actions",
|
|
||||||
error: String(error),
|
|
||||||
});
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, message: "Internal server error" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,8 @@ const ROUTES: Array<[string, string]> = [
|
|||||||
["devops", "PERMS.DEVOPS_VIEW"],
|
["devops", "PERMS.DEVOPS_VIEW"],
|
||||||
["devops/errors", "PERMS.DEVOPS_VIEW"],
|
["devops/errors", "PERMS.DEVOPS_VIEW"],
|
||||||
["online", "PERMS.USERS_VIEW"],
|
["online", "PERMS.USERS_VIEW"],
|
||||||
|
["commandocentrum", "PERMS.RCON_EXECUTE"],
|
||||||
|
["users/edit/[id]", "PERMS.USERS_EDIT"],
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("admin operations route contract", () => {
|
describe("admin operations route contract", () => {
|
||||||
@@ -29,6 +31,7 @@ describe("admin operations route contract", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
|
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
|
||||||
["devops/health", "PERMS.DEVOPS_VIEW"],
|
["devops/health", "PERMS.DEVOPS_VIEW"],
|
||||||
|
["users/actions", "PERMS.USERS_EDIT"],
|
||||||
])("provides and guards /api/admin/%s", (route, permission) => {
|
])("provides and guards /api/admin/%s", (route, permission) => {
|
||||||
const path = `src/app/api/admin/${route}/route.ts`;
|
const path = `src/app/api/admin/${route}/route.ts`;
|
||||||
expect(existsSync(path), path).toBe(true);
|
expect(existsSync(path), path).toBe(true);
|
||||||
@@ -40,4 +43,16 @@ describe("admin operations route contract", () => {
|
|||||||
expect(source).toContain("PERMS.MODERATION_EDIT");
|
expect(source).toContain("PERMS.MODERATION_EDIT");
|
||||||
expect(source).toContain("adminAction");
|
expect(source).toContain("adminAction");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("guards translation writes with SETTINGS_EDIT", () => {
|
||||||
|
const source = readFileSync("src/actions/translations.ts", "utf8");
|
||||||
|
expect(source).toContain("PERMS.SETTINGS_EDIT");
|
||||||
|
expect(source).not.toContain("rank < 7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards commandocentrum mutations with RCON_EXECUTE", () => {
|
||||||
|
const source = readFileSync("src/actions/commandocentrum.ts", "utf8");
|
||||||
|
expect(source).toContain("PERMS.RCON_EXECUTE");
|
||||||
|
expect(source).not.toContain("requireStaff()");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -22,6 +22,23 @@ export async function requireStaff(): Promise<StaffUser> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff gate plus a specific ACL slug. Use for FormData server actions that
|
||||||
|
* must not stop at admin.dashboard alone (RCON, user edits, settings writes).
|
||||||
|
*/
|
||||||
|
export async function requirePermission(permission: string): Promise<StaffUser> {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||||
|
redirectSafe("/", "/");
|
||||||
|
if (!canAccess(permissions, permission, session.user.rank))
|
||||||
|
redirectSafe("/admin", "/admin");
|
||||||
|
return {
|
||||||
|
id: session.user.id,
|
||||||
|
rank: session.user.rank,
|
||||||
|
username: session.user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function requireStaffRateLimited(): Promise<StaffUser> {
|
export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const ip = await clientIp();
|
const ip = await clientIp();
|
||||||
|
|||||||
Reference in new issue
Block a user