fix: restore complete admin feature dependencies

This commit is contained in:
Simo committed 2026-07-11 21:15:54 +02:00
1 parent 5b4228261a
commit 0cd753c735
115 files changed
+18565 -4930

No files matched your search

+5 -4
View File
@@ -5,8 +5,9 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function bulkUnban({ userIds }: { userIds: number[] }) {
export async function bulkUnban({ userIds }: { userIds: number[] }): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requireStaff();
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } });
await logStaffActivity({
@@ -18,7 +19,7 @@ export async function bulkUnban({ userIds }: { userIds: number[] }) {
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } };
}
export async function bulkBan({ userIds, reason, duration }: { userIds: number[]; reason: string; duration: number }) {
export async function bulkBan({ userIds, reason, duration }: { userIds: number[]; reason: string; duration: number }): Promise<ActionResult<{ banned: number }>> {
const staff = await requireStaff();
const now = Math.floor(Date.now() / 1000);
let banned = 0;
@@ -52,7 +53,7 @@ export async function bulkBan({ userIds, reason, duration }: { userIds: number[]
return { ok: true as const, data: { banned } };
}
export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points" }) {
export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points" }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
const staff = await requireStaff();
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
@@ -92,7 +93,7 @@ export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: num
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
}
export async function bulkGiveBadge({ userIds, badgeCode }: { userIds: number[]; badgeCode: string }) {
export async function bulkGiveBadge({ userIds, badgeCode }: { userIds: number[]; badgeCode: string }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
const staff = await requireStaff();
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
+5
View File
@@ -34,6 +34,7 @@ export async function createCatalogItem(data: {
targetId: created.id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: created.id } };
}
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
@@ -47,6 +48,7 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) {
targetType: "catalog_item",
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; targetPageId: number }) {
@@ -57,6 +59,7 @@ export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; t
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function reorderCatalogItems({ orders }: { orders: Array<{ id: number; orderNumber: number }> }) {
@@ -66,6 +69,7 @@ export async function reorderCatalogItems({ orders }: { orders: Array<{ id: numb
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: number; catalogFields: Record<string, unknown>; baseItem?: { id: number; fields: Record<string, unknown> } }) {
@@ -83,6 +87,7 @@ export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: n
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function translateCatalogItems({ items }: { items: Array<{ id: number; publicName: string; description: string }> }) {
+65 -11
View File
@@ -5,8 +5,9 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function updateCatalogPage({ id, ...fields }: { id: number } & Record<string, unknown>) {
export async function updateCatalogPage({ id, ...fields }: { id: number } & Record<string, unknown>): Promise<ActionResult> {
const staff = await requireStaff();
await prisma.catalogPages.update({ where: { id }, data: fields as any });
await rcon.updateCatalog();
@@ -18,6 +19,7 @@ export async function updateCatalogPage({ id, ...fields }: { id: number } & Reco
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function deleteCatalogPage({ id }: { id: number }) {
@@ -32,12 +34,13 @@ export async function deleteCatalogPage({ id }: { id: number }) {
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function toggleCatalogPage({ id, action }: { id: number; action: "toggleEnabled" | "toggleVisible" }) {
await requireStaff();
const page = await prisma.catalogPages.findUnique({ where: { id }, select: { enabled: true, visible: true } });
if (!page) return;
if (!page) return { ok: false as const, error: "Catalog page not found" };
const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible;
await prisma.catalogPages.update({
@@ -45,22 +48,23 @@ export async function toggleCatalogPage({ id, action }: { id: number; action: "t
data: { [field]: current === "1" ? "0" : "1" },
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function createCatalogPage(input: { caption: string; parentId: number; pageLayout: string }) {
export async function createCatalogPage(input: { caption: string; parentId: number; pageLayout?: string; iconImage?: number; iconColor?: number; enabled?: "0" | "1"; visible?: "0" | "1"; minRank?: number; orderNum?: number }): Promise<ActionResult<{ id: number }>> {
const staff = await requireStaff();
const created = await prisma.catalogPages.create({
data: {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconColor: 0,
iconImage: 0,
minRank: 1,
orderNum: 0,
visible: "1",
enabled: "1",
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
@@ -69,7 +73,7 @@ export async function createCatalogPage(input: { caption: string; parentId: numb
},
});
await logStaffActivity({
staffId: (await requireStaff()).id,
staffId: staff.id,
action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page",
@@ -78,3 +82,53 @@ export async function createCatalogPage(input: { caption: string; parentId: numb
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: created.id } };
}
export async function reorderTreePage(input: {
pageId: number;
newParentId?: number;
newOrderNum: number;
}) {
await requireStaff();
await prisma.catalogPages.update({
where: { id: input.pageId },
data: {
orderNum: input.newOrderNum,
...(input.newParentId === undefined ? {} : { parentId: input.newParentId }),
},
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function deleteTreePage(input: { pageId: number; mode: "reparent" | "cascade" }) {
await requireStaff();
if (input.mode === "cascade") {
const children = await prisma.catalogPages.findMany({
where: { parentId: input.pageId },
select: { id: true },
});
const pageIds = [input.pageId, ...children.map((child) => child.id)];
await prisma.$transaction([
prisma.catalogItems.deleteMany({ where: { pageId: { in: pageIds } } }),
prisma.catalogPages.deleteMany({ where: { id: { in: pageIds } } }),
]);
} else {
const page = await prisma.catalogPages.findUnique({
where: { id: input.pageId },
select: { parentId: true },
});
if (!page) return { ok: false as const, error: "Catalog page not found" };
await prisma.$transaction([
prisma.catalogPages.updateMany({
where: { parentId: input.pageId },
data: { parentId: page.parentId },
}),
prisma.catalogItems.deleteMany({ where: { pageId: input.pageId } }),
prisma.catalogPages.delete({ where: { id: input.pageId } }),
]);
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
+40
View File
@@ -0,0 +1,40 @@
'use server'
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { rcon } from '@/lib/services/rcon'
const saveEmulatorSettingsSchema = z.object({
settings: z.record(z.string(), z.string()),
})
export const saveEmulatorSettings = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
async (ctx) => {
const entries = Object.entries(ctx.data.settings)
for (const [key, value] of entries) {
await prisma.emulatorSettings.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value) },
})
}
await rcon.updateConfig()
logAudit({
userId: ctx.session.user.id,
action: 'emulator_settings_update',
target: 'EmulatorSettings',
after: ctx.data.settings,
})
return actionOk()
},
)
+8 -3
View File
@@ -2,10 +2,15 @@
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function cleanSwfFiles() {
await requireStaff();
return { ok: true as const, data: { deleted: 0, remaining: 0 } };
export async function cleanSwfFiles(): Promise<ActionResult<{ deleted: number; remaining: number }>> {
try {
await requireStaff();
return { ok: true, data: { deleted: 0, remaining: 0 } };
} catch {
return { ok: false, error: "Failed to clean SWF files" };
}
}
export async function deleteImportedFurni({ classname }: { classname: string }) {
+48
View File
@@ -0,0 +1,48 @@
'use server'
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
const templateSchema = z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
category: z.string().max(50).optional().default('general'),
sortOrder: z.coerce.number().int().min(0).default(0),
})
export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => {
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data })
return actionOk({ id: tpl.id })
},
)
const updateTemplateInput = templateSchema
.partial()
.extend({ id: z.coerce.number().int().positive() })
export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => {
const { id, ...data } = ctx.data
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } })
if (!existing) throw new ActionError('Template not found')
await prisma.websiteTicketTemplate.update({ where: { id }, data })
return actionOk({ id })
},
)
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() })
export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => {
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } })
return actionOk()
},
)
+230
View File
@@ -0,0 +1,230 @@
'use server'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction, authAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { notify } from '@/lib/services/webhook'
import {
assignTicketSchema,
createTicketSchema,
replyTicketSchema,
updateTicketPrioritySchema,
updateTicketStatusSchema,
} from '@/lib/validators/ticket'
// ── User actions (authenticated, no admin perms needed) ──────────────
export const createTicket = authAction({ schema: createTicketSchema }, async (ctx) => {
const ticket = await prisma.websiteTicket.create({
data: {
subject: ctx.data.subject,
category: ctx.data.category,
creatorId: ctx.session.user.id,
},
})
// Create the first message
await prisma.websiteTicketMessage.create({
data: {
ticketId: ticket.id,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 0,
},
})
notify({
action: 'ticket_create',
actor: ctx.session.user.username,
target: `#${ticket.id} - ${ticket.subject}`,
details: `Category: ${ticket.category}`,
})
return actionOk({ id: ticket.id })
})
export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
if (!ticket) throw new ActionError('Ticket not found')
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized')
if (ticket.status === 'closed') throw new ActionError('Ticket is closed')
await prisma.websiteTicketMessage.create({
data: {
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 0,
},
})
// If ticket was in "waiting" (waiting for user), move back to open
if (ticket.status === 'waiting') {
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { status: 'open' },
})
}
return actionOk()
})
export const closeTicketByUser = authAction(
{ schema: replyTicketSchema.pick({ ticketId: true }) },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
if (!ticket) throw new ActionError('Ticket not found')
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError('Unauthorized')
if (ticket.status === 'closed') throw new ActionError('Ticket is already closed')
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { status: 'closed', closedAt: new Date() },
})
return actionOk()
},
)
// ── Admin actions ────────────────────────────────────────────────────
export const adminReplyTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
if (!ticket) throw new ActionError('Ticket not found')
await prisma.websiteTicketMessage.create({
data: {
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 1,
},
})
// Auto-assign if not assigned yet
const updates: Record<string, unknown> = { status: 'waiting' }
if (!ticket.assigneeId) {
updates.assigneeId = ctx.session.user.id
}
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: updates,
})
logAudit({
userId: ctx.session.user.id,
action: 'ticket_reply',
target: 'WebsiteTicket',
targetId: ctx.data.ticketId,
})
return actionOk()
},
)
export const updateTicketStatus = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
if (!ticket) throw new ActionError('Ticket not found')
const data: Record<string, unknown> = { status: ctx.data.status }
if (ctx.data.status === 'closed') {
data.closedAt = new Date()
}
if (ctx.data.status === 'in_progress' && !ticket.assigneeId) {
data.assigneeId = ctx.session.user.id
}
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data,
})
logAudit({
userId: ctx.session.user.id,
action: 'ticket_status_change',
target: 'WebsiteTicket',
targetId: ctx.data.ticketId,
before: { status: ticket.status },
after: { status: ctx.data.status },
})
return actionOk()
},
)
export const assignTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
if (!ticket) throw new ActionError('Ticket not found')
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: {
assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? 'in_progress' : 'open',
},
})
logAudit({
userId: ctx.session.user.id,
action: 'ticket_assign',
target: 'WebsiteTicket',
targetId: ctx.data.ticketId,
before: { assigneeId: ticket.assigneeId },
after: { assigneeId: ctx.data.assigneeId },
})
return actionOk()
},
)
export const updateTicketPriority = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
})
if (!ticket) throw new ActionError('Ticket not found')
await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId },
data: { priority: ctx.data.priority },
})
logAudit({
userId: ctx.session.user.id,
action: 'ticket_priority_change',
target: 'WebsiteTicket',
targetId: ctx.data.ticketId,
before: { priority: ticket.priority },
after: { priority: ctx.data.priority },
})
return actionOk()
},
)
+77
View File
@@ -0,0 +1,77 @@
'use server'
import fs from 'node:fs/promises'
import path from 'node:path'
import JSON5 from 'json5'
import { z } from 'zod'
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from '@/lib/client-translation-files'
import { patchJson5 } from '@/lib/json5-patch'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
const saveTranslationsSchema = z.object({
locale: z.enum(['en', 'it']),
data: z.record(z.string(), z.unknown()),
})
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
const filePath = path.join(process.cwd(), 'messages', `${ctx.data.locale}.json`)
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), 'utf-8')
return actionOk()
})
const saveClientTranslationsSchema = z.object({
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]),
data: z.record(z.string(), z.string()),
})
export const saveClientTranslations = adminAction(
{ 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')
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's static tracer can't prove that — without the hint it
// pulls the entire project into the NFT list.
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath)
const raw = await fs.readFile(absPath, 'utf-8')
if (file.format === 'json') {
// Plain JSON — no comments to preserve, just round-trip.
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), 'utf-8')
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] })
}
// JSON5: surgical line-level patch keeps headers and section comments
// intact. Falls back to a full re-serialization (which DOES drop comments)
// only when an edited key cannot be located via the patch contract.
const original: Record<string, string> = {}
const parsed = JSON5.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) {
original[k] = v == null ? '' : String(v)
}
}
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data)
if (unpatchedKeys.length === 0) {
await fs.writeFile(absPath, content, 'utf-8')
return actionOk({ commentsLost: false, unpatchedKeys })
}
// At least one key could not be patched surgically (e.g. unusual
// formatting or a brand-new key). Fall back to a full re-serialization
// and warn the caller that comments were lost.
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), 'utf-8')
return actionOk({ commentsLost: true, unpatchedKeys })
},
)
+435
View File
@@ -0,0 +1,435 @@
'use server'
import crypto from 'node:crypto'
import { hash } from 'bcryptjs'
import { z } from 'zod'
import { Prisma } from '@/generated/prisma/client'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { rcon } from '@/lib/services/rcon'
import { notify } from '@/lib/services/webhook'
import {
banUserSchema,
createUserSchema,
giveBadgeSchema,
updateUserSchema,
} from '@/lib/validators/user'
const DEFAULT_LOOK = 'hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64'
export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => {
const { username, mail, password, rank, motto } = ctx.data
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
throw new ActionError('Cannot assign rank equal or higher than your own')
}
const hashedPassword = await hash(password, 12)
const now = Math.floor(Date.now() / 1000)
try {
const user = await prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
username,
mail,
password: hashedPassword,
rank,
motto: motto || "I'm new here!",
look: DEFAULT_LOOK,
credits: 5000,
pixels: 5000,
accountCreated: now,
ipRegister: '0.0.0.0',
ipCurrent: '0.0.0.0',
},
})
await tx.usersSettings.create({ data: { userId: created.id } })
await tx.usersCurrency.createMany({
data: [
{ userId: created.id, type: 0, amount: 5000 },
{ userId: created.id, type: 5, amount: 5000 },
],
})
return created
})
logAudit({
userId: ctx.session.user.id,
action: 'user_create',
target: 'User',
targetId: user.id,
after: { username, mail, rank },
})
notify({
action: 'user_edit',
actor: ctx.session.user.username,
target: username,
targetId: user.id,
details: 'Account created by admin',
})
return actionOk({ id: user.id, username: user.username })
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const target = (err.meta?.target as string[]) ?? []
if (target.includes('username')) throw new ActionError('Username already taken')
if (target.includes('mail')) throw new ActionError('Email already registered')
throw new ActionError('Username or email already in use')
}
throw err
}
},
)
const updateUserInput = updateUserSchema.extend({
id: z.coerce.number().int().positive(),
})
export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => {
const { id, diamonds, duckets, ...userData } = ctx.data
const targetUser = await guardRank(id, ctx.session.user.rank)
if (
userData.rank !== undefined &&
userData.rank >= ctx.session.user.rank &&
ctx.session.user.rank < 7
) {
throw new ActionError('Cannot assign rank equal or higher than your own')
}
await prisma.user.update({ where: { id }, data: userData })
if (diamonds !== undefined) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 5 } },
update: { amount: diamonds },
create: { userId: id, type: 5, amount: diamonds },
})
}
if (duckets !== undefined) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 0 } },
update: { amount: duckets },
create: { userId: id, type: 0, amount: duckets },
})
}
logAudit({
userId: ctx.session.user.id,
action: 'user_edit',
target: 'User',
targetId: id,
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
after: userData,
})
notify({
action: 'user_edit',
actor: ctx.session.user.username,
target: targetUser.username,
targetId: id,
})
return actionOk()
},
)
const banInput = banUserSchema.extend({})
export const banUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: banInput },
async (ctx) => {
const { userId, reason, duration, type, ip } = ctx.data
const targetUser = await guardRank(userId, ctx.session.user.rank)
const now = Math.floor(Date.now() / 1000)
const banExpire = duration > 0 ? now + duration * 3600 : 0
await prisma.ban.create({
data: {
userId,
userStaffId: ctx.session.user.id,
timestamp: now,
banExpire,
banReason: reason,
type: type || 'account',
ip: ip || '',
machineId: '',
},
})
await rcon.disconnectUser(userId)
logAudit({
userId: ctx.session.user.id,
action: 'ban',
target: 'User',
targetId: userId,
after: { reason, type, duration },
})
notify({
action: 'ban',
actor: ctx.session.user.username,
target: targetUser.username,
details: reason,
})
return actionOk()
},
)
const unbanInput = z.object({ userId: z.coerce.number().int().positive() })
export const unbanUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: unbanInput },
async (ctx) => {
const { userId } = ctx.data
const targetUser = await guardRank(userId, ctx.session.user.rank)
await prisma.ban.deleteMany({ where: { userId } })
logAudit({
userId: ctx.session.user.id,
action: 'unban',
target: 'User',
targetId: userId,
})
notify({
action: 'unban',
actor: ctx.session.user.username,
target: targetUser.username,
})
return actionOk()
},
)
export const giveBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
async (ctx) => {
const { userId, badgeCode } = ctx.data
await guardRank(userId, ctx.session.user.rank)
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
if (existing) throw new ActionError('Badge already assigned')
await prisma.usersBadges.create({ data: { userId, badgeCode } })
await rcon.giveBadge(userId, badgeCode)
return actionOk()
},
)
// ── Remove Badge ────────────────────────────────────────────────────
const removeBadgeSchema = z.object({
userId: z.coerce.number().int().positive(),
badgeCode: z.string().min(1),
})
export const removeBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
async (ctx) => {
const { userId, badgeCode } = ctx.data
await guardRank(userId, ctx.session.user.rank)
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
if (!existing) throw new ActionError('Badge not found')
await prisma.usersBadges.delete({ where: { id: existing.id } })
await rcon.removeBadge(userId, badgeCode)
return actionOk()
},
)
// ── Rank guard helper ───────────────────────────────────────────────
async function guardRank(targetUserId: number, sessionRank: number) {
const target = await prisma.user.findUnique({
where: { id: targetUserId },
select: { username: true, rank: true, mail: true },
})
if (!target) throw new ActionError('User not found')
if (target.rank >= sessionRank && sessionRank < 7) {
throw new ActionError('Cannot modify user with equal or higher rank')
}
return target
}
// ── Reset Password ──────────────────────────────────────────────────
const resetPasswordSchema = z.object({
userId: z.coerce.number().int().positive(),
})
export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
const newPassword = crypto.randomBytes(12).toString('base64url').slice(0, 16)
const hashed = await hash(newPassword, 10)
await prisma.user.update({
where: { id: ctx.data.userId },
data: { password: hashed },
})
logAudit({
userId: ctx.session.user.id,
action: 'reset_password',
target: 'User',
targetId: ctx.data.userId,
})
notify({
action: 'user_edit',
actor: ctx.session.user.username,
target: target.username,
details: 'Password reset',
})
return actionOk({ newPassword })
},
)
// ── Disconnect User ─────────────────────────────────────────────────
const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(),
})
export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
const success = await rcon.disconnectUser(ctx.data.userId)
if (!success) throw new ActionError('Failed to disconnect. Is the emulator running?')
logAudit({
userId: ctx.session.user.id,
action: 'user_disconnect',
target: 'User',
targetId: ctx.data.userId,
})
notify({
action: 'disconnect',
actor: ctx.session.user.username,
target: target.username,
})
return actionOk()
},
)
// ── Alert User (in-game message) ────────────────────────────────────
const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(),
message: z.string().min(1).max(500),
})
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message)
if (!success) throw new ActionError('Failed to send alert. Is the emulator running?')
return actionOk()
},
)
// ── Mute User ───────────────────────────────────────────────────────
const muteSchema = z.object({
userId: z.coerce.number().int().positive(),
duration: z.coerce.number().int().min(0).default(0),
})
export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration)
if (!success) throw new ActionError('Failed to mute. Is the emulator running?')
logAudit({
userId: ctx.session.user.id,
action: 'user_mute',
target: 'User',
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
})
return actionOk()
},
)
// ── Unmute User ─────────────────────────────────────────────────────
const unmuteSchema = z.object({
userId: z.coerce.number().int().positive(),
})
export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
async (ctx) => {
await guardRank(ctx.data.userId, ctx.session.user.rank)
const success = await rcon.unmuteUser(ctx.data.userId)
if (!success) throw new ActionError('Failed to unmute. Is the emulator running?')
logAudit({
userId: ctx.session.user.id,
action: 'user_unmute',
target: 'User',
targetId: ctx.data.userId,
})
return actionOk()
},
)
// ── Send Credits via RCON ───────────────────────────────────────────
const sendCreditsSchema = z.object({
userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().min(1).max(1000000),
})
export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount)
if (!success) throw new ActionError('Failed to send credits. Is the emulator running?')
logAudit({
userId: ctx.session.user.id,
action: 'user_send_credits',
target: 'User',
targetId: ctx.data.userId,
after: { amount: ctx.data.amount },
})
return actionOk()
},
)
+44
View File
@@ -0,0 +1,44 @@
'use server'
import { revalidateTag } from 'next/cache'
import { z } from 'zod'
import { PERMS } from '@/lib/permission-slugs'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { actionOk } from '@/lib/safe-action-shared'
const toggleWatchSchema = z.object({
targetUserId: z.coerce.number().int().positive(),
reason: z.string().max(255).optional(),
})
/**
* Toggle a watch entry for the calling staff on the given target user.
* If a row already exists it's removed; otherwise it's created with the
* provided reason (defaulting to empty). Returns the resulting state so
* the UI can flip the badge without re-fetching.
*/
export const toggleUserWatch = adminAction(
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
async (ctx) => {
const staffId = ctx.session.user.id
const { targetUserId, reason } = ctx.data
const existing = await prisma.userWatch.findUnique({
where: { staffId_targetUserId: { staffId, targetUserId } },
})
if (existing) {
await prisma.userWatch.delete({ where: { id: existing.id } })
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
return actionOk({ watching: false })
}
await prisma.userWatch.create({
data: { staffId, targetUserId, reason: reason ?? '' },
})
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
return actionOk({ watching: true })
},
)