'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() }, )