fix: restore complete admin feature dependencies
This commit is contained in:
1 parent
5b4228261a
commit
0cd753c735
115 files changed
+18565
-4930
No files matched your search
@@ -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 }> = [];
|
||||
|
||||
@@ -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
@@ -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: {} };
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
@@ -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 })
|
||||
},
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user