diff --git a/src/actions/admin-user-edit.ts b/src/actions/admin-user-edit.ts index 3f410f59..a5e3abfd 100644 --- a/src/actions/admin-user-edit.ts +++ b/src/actions/admin-user-edit.ts @@ -2,7 +2,8 @@ import { revalidatePath } from "next/cache"; 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 { 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. */ export async function updateUser(formData: FormData): Promise { - // Never trust the client: re-check staff inside the action. - const staff = await requireStaff(); + // Never trust the client: re-check USERS_EDIT inside the action. + const staff = await requirePermission(PERMS.USERS_EDIT); const userId = Number(formData.get("id")); if (!Number.isInteger(userId) || userId <= 0) return; @@ -95,6 +96,6 @@ export async function updateUser(formData: FormData): Promise { }); revalidatePath(`/admin/users/${userId}`); - revalidatePath(`/admin/users/${userId}/edit`); - redirect(`/admin/users/${userId}`); + revalidatePath(`/admin/users/edit/${userId}`); + redirect(`/admin/users/show/${userId}`); } diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts index 9279e3b4..5c3576e7 100644 --- a/src/actions/commandocentrum.ts +++ b/src/actions/commandocentrum.ts @@ -1,14 +1,19 @@ "use server"; 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"; const PATH = "/admin/commandocentrum"; +async function requireRcon(): Promise { + await requirePermission(PERMS.RCON_EXECUTE); +} + /** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */ export async function updateCatalog(): Promise { - await requireStaff(); + await requireRcon(); try { await rcon.updateCatalog(); } catch { @@ -19,7 +24,7 @@ export async function updateCatalog(): Promise { /** Reload the chat word filter on the emulator (rcon: updatewordfilter). */ export async function updateWordFilter(): Promise { - await requireStaff(); + await requireRcon(); try { await rcon.updateWordFilter(); } catch { @@ -30,7 +35,7 @@ export async function updateWordFilter(): Promise { /** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */ export async function updateNavigator(): Promise { - await requireStaff(); + await requireRcon(); try { await rcon.send("updatenavigator", null); } catch { @@ -41,7 +46,7 @@ export async function updateNavigator(): Promise { /** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */ export async function hotelAlert(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const message = String(formData.get("message") ?? "") .normalize("NFC") .trim() @@ -57,7 +62,7 @@ export async function hotelAlert(formData: FormData): Promise { /** Disconnect/kick a user from the hotel (rcon: disconnect). */ export async function disconnectUser(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const username = String(formData.get("username") ?? "") .normalize("NFC") @@ -73,7 +78,7 @@ export async function disconnectUser(formData: FormData): Promise { /** Send an alert to a specific user (rcon: alertuser). */ export async function alertUser(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const message = String(formData.get("message") ?? "") .normalize("NFC") @@ -90,7 +95,7 @@ export async function alertUser(formData: FormData): Promise { /** Forward a user to a specific room (rcon: forwarduser). */ export async function forwardUser(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const roomId = Number(formData.get("roomId")); if (!userId || !roomId) return; @@ -104,7 +109,7 @@ export async function forwardUser(formData: FormData): Promise { /** Give credits to a user (rcon: givecredits). */ export async function giveCredits(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const credits = Number(formData.get("credits")); if (!userId || !credits || credits <= 0) return; @@ -118,7 +123,7 @@ export async function giveCredits(formData: FormData): Promise { /** Give duckets to a user (rcon: givepoints type=duckets). */ export async function giveDuckets(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const amount = Number(formData.get("amount")); if (!userId || !amount || amount <= 0) return; @@ -132,7 +137,7 @@ export async function giveDuckets(formData: FormData): Promise { /** Give diamonds to a user (rcon: givepoints type=diamonds). */ export async function giveDiamonds(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const amount = Number(formData.get("amount")); if (!userId || !amount || amount <= 0) return; @@ -146,7 +151,7 @@ export async function giveDiamonds(formData: FormData): Promise { /** Give a badge to a user (rcon: givebadge). */ export async function giveBadge(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const badge = String(formData.get("badge") ?? "") .normalize("NFC") @@ -162,7 +167,7 @@ export async function giveBadge(formData: FormData): Promise { /** Set a user's motto (rcon: setmotto). */ export async function setMotto(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const motto = String(formData.get("motto") ?? "") .normalize("NFC") @@ -179,7 +184,7 @@ export async function setMotto(formData: FormData): Promise { /** Set a user's rank (rcon: setrank). */ export async function setRank(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const rank = Number(formData.get("rank")); if (!userId || rank < 0 || rank > 10) return; @@ -193,7 +198,7 @@ export async function setRank(formData: FormData): Promise { /** Execute a command as a user (rcon: executecommand). */ export async function executeCommand(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const command = String(formData.get("command") ?? "") .normalize("NFC") @@ -209,7 +214,7 @@ export async function executeCommand(formData: FormData): Promise { /** Send a gift to a user (rcon: sendgift). */ export async function sendGift(formData: FormData): Promise { - await requireStaff(); + await requireRcon(); const userId = Number(formData.get("userId")); const itemId = Number(formData.get("itemId")); const message = String(formData.get("message") ?? "Here is a gift.") diff --git a/src/actions/translations.ts b/src/actions/translations.ts index 997d2d5f..d29156ab 100644 --- a/src/actions/translations.ts +++ b/src/actions/translations.ts @@ -9,6 +9,7 @@ import { getClientTranslationFile, } from "@/lib/client-translation-files"; import { patchJson5 } from "@/lib/json5-patch"; +import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; @@ -41,10 +42,8 @@ const saveTranslationsSchema = z.object({ }); export const saveTranslations = adminAction( - { schema: saveTranslationsSchema }, + { permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema }, async (ctx) => { - if (ctx.session.user.rank < 7) throw new ActionError("Forbidden"); - const filePath = path.join( process.cwd(), "messages", @@ -68,10 +67,8 @@ const saveClientTranslationsSchema = z.object({ }); export const saveClientTranslations = adminAction( - { schema: saveClientTranslationsSchema }, + { permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema }, async (ctx) => { - if (ctx.session.user.rank < 7) throw new ActionError("Forbidden"); - const file = getClientTranslationFile(ctx.data.fileId); if (!file) throw new ActionError("Unknown file"); if (file.readOnly) throw new ActionError("File is read-only"); diff --git a/src/app/admin/commandocentrum/page.tsx b/src/app/admin/commandocentrum/page.tsx index 99d365da..4eaa5b17 100644 --- a/src/app/admin/commandocentrum/page.tsx +++ b/src/app/admin/commandocentrum/page.tsx @@ -1,5 +1,6 @@ import os from "node:os"; import { Activity } from "lucide-react"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { alertUser, @@ -24,6 +25,7 @@ import { OnlineUsersWidget, StatusCard, } from "@/components/admin/dashboard"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { siteSettings } from "@/lib/services/site-settings"; @@ -61,6 +63,11 @@ function uptime(seconds: number): string { } 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 [ diff --git a/src/app/admin/users/[id]/edit/page.tsx b/src/app/admin/users/[id]/edit/page.tsx index fa05bd62..5fb7b2e5 100644 --- a/src/app/admin/users/[id]/edit/page.tsx +++ b/src/app/admin/users/[id]/edit/page.tsx @@ -1,197 +1,16 @@ -import { User } from "lucide-react"; -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"; +import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; -// users_currency.type values for the non-credits currencies (see send-currency.ts). -const DUCKETS_TYPE = 0; -const DIAMONDS_TYPE = 5; - -export default async function AdminUserEdit({ +/** + * Legacy URL — the canonical edit UI lives at /admin/users/edit/[id] + * and is gated by PERMS.USERS_EDIT. + */ +export default async function AdminUserEditRedirect({ params, }: { params: Promise<{ id: string }>; }) { - // Gate the page with the SAME helper the action re-checks. - await requireStaff(); - const { id } = await params; - const t = await getTranslations("pages.admin.users.editForm"); - 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 ( -
-

- ← {t("title")} -

-

{t("title")}

-

- {t("loadError")} -

-
- ); - } - - if (!user) notFound(); - - return ( -
-

- ← {user.username} -

-
-
- -
-
-

- {t("title", { username: user.username })} -

-

- {t("idLabel", { id: user.id })} -

-
-
- -
- - -
- - - - - - - - - - - - - - - - - -
- -

- {t("hint")} -

- -
- - - {t("cancel")} - -
-
-
- ); + redirect(`/admin/users/edit/${id}`); } diff --git a/src/app/api/admin/users/actions/route.ts b/src/app/api/admin/users/actions/route.ts index e1e1a66c..81acd120 100644 --- a/src/app/api/admin/users/actions/route.ts +++ b/src/app/api/admin/users/actions/route.ts @@ -1,41 +1,14 @@ -"use server"; - import { NextResponse } from "next/server"; -import { requireStaff } from "@/lib/admin/guard"; -import { logger } from "@/lib/logger"; +import { withAdmin } from "@/lib/api-handler"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; -const giveCurrency = async ({ - rconClient: _rconClient, - db: _db, - userId, - 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(); +export const POST = withAdmin( + { permission: PERMS.USERS_EDIT }, + async (request, context) => { + const staffId = context.session.user.id; const formData = await request.formData(); const userId = Number(formData.get("userId")); 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 rcon.setRank(userId, rank); await logStaffActivity({ - staffId: staff.id, + staffId, action: "rank_change", description: `Set rank of user #${userId} to ${rank}`, targetType: "user", @@ -75,6 +48,13 @@ export async function POST(request: Request) { if (action === "disconnect") { await rcon.disconnectUser(userId, username); + await logStaffActivity({ + staffId, + action: "disconnect", + description: `Disconnected user #${userId} (${username})`, + targetType: "user", + targetId: userId, + }); return NextResponse.json( { success: true, @@ -95,6 +75,13 @@ export async function POST(request: Request) { ); } await rcon.alertUser(userId, message); + await logStaffActivity({ + staffId, + action: "alert_user", + description: `Sent alert to user #${userId}`, + targetType: "user", + targetId: userId, + }); return NextResponse.json( { success: true, message: `Sent alert to user #${userId}` }, { status: 200 }, @@ -109,12 +96,13 @@ export async function POST(request: Request) { { status: 400 }, ); } - await giveCurrency({ - rconClient: rcon, - db: prisma, - userId, - type: "credits", - amount: credits, + await rcon.giveCredits(userId, credits); + await logStaffActivity({ + staffId, + action: "give_currency", + description: `Gave ${credits} credits to user #${userId}`, + targetType: "user", + targetId: userId, }); return NextResponse.json( { @@ -133,12 +121,13 @@ export async function POST(request: Request) { { status: 400 }, ); } - await giveCurrency({ - rconClient: rcon, - db: prisma, - userId, - type: "duckets", - amount, + await rcon.giveDuckets(userId, amount); + await logStaffActivity({ + staffId, + action: "give_currency", + description: `Gave ${amount} duckets to user #${userId}`, + targetType: "user", + targetId: userId, }); return NextResponse.json( { success: true, message: `Gave ${amount} duckets to user #${userId}` }, @@ -154,12 +143,13 @@ export async function POST(request: Request) { { status: 400 }, ); } - await giveCurrency({ - rconClient: rcon, - db: prisma, - userId, - type: "diamonds", - amount, + await rcon.giveDiamonds(userId, amount); + await logStaffActivity({ + staffId, + action: "give_currency", + description: `Gave ${amount} diamonds to user #${userId}`, + targetType: "user", + targetId: userId, }); return NextResponse.json( { @@ -178,12 +168,13 @@ export async function POST(request: Request) { { status: 400 }, ); } - await giveCurrency({ - rconClient: rcon, - db: prisma, - userId, - type: "points", - amount, + await rcon.givePointsGotw(userId, amount); + await logStaffActivity({ + staffId, + action: "give_currency", + description: `Gave ${amount} points to user #${userId}`, + targetType: "user", + targetId: userId, }); return NextResponse.json( { 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}` }, { 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 }, - ); - } -} + }, +); diff --git a/src/lib/admin-operations-contract.test.ts b/src/lib/admin-operations-contract.test.ts index 273226ac..d43b9444 100644 --- a/src/lib/admin-operations-contract.test.ts +++ b/src/lib/admin-operations-contract.test.ts @@ -17,6 +17,8 @@ const ROUTES: Array<[string, string]> = [ ["devops", "PERMS.DEVOPS_VIEW"], ["devops/errors", "PERMS.DEVOPS_VIEW"], ["online", "PERMS.USERS_VIEW"], + ["commandocentrum", "PERMS.RCON_EXECUTE"], + ["users/edit/[id]", "PERMS.USERS_EDIT"], ]; describe("admin operations route contract", () => { @@ -29,6 +31,7 @@ describe("admin operations route contract", () => { it.each([ ["analytics/export", "PERMS.ANALYTICS_EXPORT"], ["devops/health", "PERMS.DEVOPS_VIEW"], + ["users/actions", "PERMS.USERS_EDIT"], ])("provides and guards /api/admin/%s", (route, permission) => { const path = `src/app/api/admin/${route}/route.ts`; expect(existsSync(path), path).toBe(true); @@ -40,4 +43,16 @@ describe("admin operations route contract", () => { expect(source).toContain("PERMS.MODERATION_EDIT"); 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()"); + }); }); diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts index 9f2b02a7..4a539a8b 100644 --- a/src/lib/admin/guard.ts +++ b/src/lib/admin/guard.ts @@ -22,6 +22,23 @@ export async function requireStaff(): Promise { }; } +/** + * 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 { + 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 { const staff = await requireStaff(); const ip = await clientIp();