Files
Epicnabbo-Catalogus-Updated…/src/actions/admin-user-edit.ts
T
simoleo89andCursor f2427b3483 Harden admin ACL on critical write paths.
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]>
2026-07-15 20:07:15 +02:00

102 lines
3.2 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// users_currency.type values for the non-credits currencies (mirror send-currency.ts).
// Credits live on users.credits; pixels/points live on the users row too;
// duckets/diamonds live in users_currency keyed by (user_id, type).
const DUCKETS_TYPE = 0;
const DIAMONDS_TYPE = 5;
function toInt(value: FormDataEntryValue | null, min = 0): number | null {
if (value == null) return null;
const raw = String(value).trim();
if (raw === "") return null;
const n = Number(raw);
if (!Number.isFinite(n)) return null;
const i = Math.trunc(n);
return i < min ? min : i;
}
/**
* Edit the SAFE website-managed fields of a users row (and the duckets/diamonds
* balances in users_currency). Never touches the password. Re-reads the staff
* user from the session and logs the action. emulator-owned users.id is Int.
*/
export async function updateUser(formData: FormData): Promise<void> {
// 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;
const existing = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!existing) return;
// users row — only existing, safe columns.
const mailRaw = String(formData.get("mail") ?? "")
.normalize("NFC")
.trim();
const motto = String(formData.get("motto") ?? "")
.normalize("NFC")
.slice(0, 127);
const look = String(formData.get("look") ?? "")
.normalize("NFC")
.slice(0, 256);
const rank = toInt(formData.get("rank"), 1);
const credits = toInt(formData.get("credits"), 0);
const pixels = toInt(formData.get("pixels"), 0);
const points = toInt(formData.get("points"), 0);
await prisma.user.update({
where: { id: userId },
data: {
mail: mailRaw === "" ? null : mailRaw.slice(0, 500),
motto,
look,
...(rank != null ? { rank } : {}),
...(credits != null ? { credits } : {}),
...(pixels != null ? { pixels } : {}),
...(points != null ? { points } : {}),
},
});
// users_currency — set exact balances for duckets / diamonds.
const duckets = toInt(formData.get("duckets"), 0);
const diamonds = toInt(formData.get("diamonds"), 0);
if (duckets != null) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: DUCKETS_TYPE } },
update: { amount: duckets },
create: { userId, type: DUCKETS_TYPE, amount: duckets },
});
}
if (diamonds != null) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: DIAMONDS_TYPE } },
update: { amount: diamonds },
create: { userId, type: DIAMONDS_TYPE, amount: diamonds },
});
}
await logStaffActivity({
staffId: staff.id,
action: "user_edit",
description: `Edited account fields of user #${userId}`,
targetType: "user",
targetId: userId,
});
revalidatePath(`/admin/users/${userId}`);
revalidatePath(`/admin/users/edit/${userId}`);
redirect(`/admin/users/show/${userId}`);
}