"use server"; import { randomUUID } from "node:crypto"; import { formatManagedDiscordNickname, lookupJavaProfile, updateGuildNickname, } from "@minecraft-account-manager/minecraft"; import { events, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database"; import { getClientIp } from "@minecraft-account-manager/network"; import { and, eq, isNull, ne, sql } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { recordAdminEvent } from "@/lib/audit"; import { requireAdminSession } from "@/lib/auth/require-admin"; import { db } from "@/lib/database"; import { adminGroupReturnPath } from "@/lib/group-management"; const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function userPath(userId: string, query?: string) { return `/admin/users/${encodeURIComponent(userId)}${query ? `?${query}` : ""}`; } async function targetUser(userId: string) { if (!UUID_PATTERN.test(userId)) return null; const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); return user ?? null; } async function primaryUsername(userId: string) { const [account] = await db .select({ username: minecraftAccounts.username }) .from(minecraftAccounts) .where( and( eq(minecraftAccounts.userId, userId), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt), ), ) .limit(1); return account?.username ?? null; } async function synchronizeNickname(input: { discordUserId: string; firstName: string; minecraftUsername: string | null; }) { const guildId = process.env.DISCORD_GUILD_ID?.trim(); const botToken = process.env.DISCORD_BOT_TOKEN?.trim(); if (!guildId || !botToken) throw new Error("Discord nickname updates are not configured"); const nickname = formatManagedDiscordNickname(input.firstName, input.minecraftUsername); await updateGuildNickname({ guildId, discordUserId: input.discordUserId, nickname, botToken, }); return nickname; } async function recordSyncFailure( admin: Awaited>, userId: string, operation: string, ) { await recordAdminEvent( admin, userId, "games.minecraft.account-manager.discord.nickname.update-failed", { operation }, ); } export async function assignUserGroupFromRegistry(formData: FormData) { const admin = await requireAdminSession(); const userId = String(formData.get("userId") ?? ""); const groupId = String(formData.get("groupId") ?? ""); const returnTo = formData.get("returnTo"); if (!UUID_PATTERN.test(userId) || !UUID_PATTERN.test(groupId)) redirect(adminGroupReturnPath(returnTo, "error=invalid-group-assignment")); const requestHeaders = await headers(); const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true"); try { await db.transaction(async (tx) => { await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-membership'))`); await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`); await tx.execute(sql`select ${users.id} from ${users} where ${users.id} = ${userId} for update`); const [[user], [targetGroup]] = await Promise.all([ tx.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1), tx.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1), ]); if (!user || !targetGroup) throw new Error("User or destination group no longer exists"); const [membership] = await tx.select({ groupId: userGroupMemberships.groupId }) .from(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id)).limit(1); if (membership && membership.groupId !== targetGroup.id) { await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${membership.groupId} for update`); } const [previous] = await tx.select({ id: groups.id, name: groups.name }) .from(userGroupMemberships) .innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)) .where(eq(userGroupMemberships.userId, user.id)) .limit(1); if (targetGroup.isDefault) { await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id)); } else { await tx.insert(userGroupMemberships).values({ userId: user.id, groupId: targetGroup.id }) .onConflictDoUpdate({ target: userGroupMemberships.userId, set: { groupId: targetGroup.id }, }); } await tx.insert(events).values({ id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.group.assignment-updated", subject: `user/${user.id}`, time: new Date(), data: { groupId: targetGroup.id, groupName: targetGroup.name, previousGroupId: previous?.id ?? null, previousGroupName: previous?.name ?? "everyone", adminEmail: admin.email, adminName: admin.name, }, ipAddress: ipAddress ?? null, }); }); } catch { redirect(adminGroupReturnPath(returnTo, "error=invalid-group-assignment")); } redirect(adminGroupReturnPath(returnTo, "saved=group")); } export async function updateUserName(formData: FormData) { const admin = await requireAdminSession(); const userId = String(formData.get("userId") ?? ""); const firstName = String(formData.get("firstName") ?? "").trim(); const user = await targetUser(userId); if (!user) redirect("/admin/users?error=unknown-user"); if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) { redirect(userPath(user.id, "error=invalid-name")); } const minecraftUsername = await primaryUsername(user.id); let nickname: string; try { nickname = await synchronizeNickname({ discordUserId: user.discordUserId, firstName, minecraftUsername, }); } catch { await recordSyncFailure(admin, user.id, "update-first-name"); redirect(userPath(user.id, "error=discord-update")); } await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id)); await recordAdminEvent( admin, user.id, "games.minecraft.account-manager.user.first-name.updated", { firstName, nickname }, ); await recordAdminEvent( admin, user.id, "games.minecraft.account-manager.discord.nickname.updated", { nickname, operation: "update-first-name" }, ); redirect(userPath(user.id, "saved=name")); } export async function addUserMinecraftAccount(formData: FormData) { const admin = await requireAdminSession(); const userId = String(formData.get("userId") ?? ""); const requestedUsername = String(formData.get("username") ?? "").trim(); const forceUnverified = formData.get("forceUnverified") === "yes"; const user = await targetUser(userId); if (!user) redirect("/admin/users?error=unknown-user"); if (!USERNAME_PATTERN.test(requestedUsername)) { redirect(userPath(user.id, "error=invalid-username")); } let profile: Awaited>; try { profile = await lookupJavaProfile(requestedUsername); } catch { redirect(userPath(user.id, "error=mojang-unavailable")); } if (!profile && !forceUnverified) { redirect(userPath(user.id, `unverified=${encodeURIComponent(requestedUsername)}`)); } const [existing] = await db .select({ id: minecraftAccounts.id }) .from(minecraftAccounts) .where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt))) .limit(1); let account: { id: string } | undefined; try { [account] = await db .insert(minecraftAccounts) .values({ userId: user.id, minecraftUuid: profile?.uuid ?? null, username: profile?.username ?? requestedUsername, validationStatus: profile ? "verified" : "user_confirmed", lastVerifiedAt: profile ? new Date() : null, isPrimary: !existing, }) .returning({ id: minecraftAccounts.id }); } catch { redirect(userPath(user.id, "error=already-registered")); } const username = profile?.username ?? requestedUsername; let nickname: string | null = null; if (!existing && user.firstName && account) { try { nickname = await synchronizeNickname({ discordUserId: user.discordUserId, firstName: user.firstName, minecraftUsername: username, }); } catch { await db.delete(minecraftAccounts).where(eq(minecraftAccounts.id, account.id)); await recordSyncFailure(admin, user.id, "add-first-account"); redirect(userPath(user.id, "error=discord-update")); } } await recordAdminEvent( admin, user.id, "games.minecraft.account-manager.minecraft-account.added", { accountId: account?.id, username, minecraftUuid: profile?.uuid ?? null, validationStatus: profile ? "verified" : "user_confirmed", }, ); if (nickname) { await recordAdminEvent( admin, user.id, "games.minecraft.account-manager.discord.nickname.updated", { nickname, operation: "add-first-account" }, ); } redirect(userPath(user.id, "saved=account-added")); } export async function setUserPrimaryAccount(formData: FormData) { const admin = await requireAdminSession(); const userId = String(formData.get("userId") ?? ""); const accountId = String(formData.get("accountId") ?? ""); const user = await targetUser(userId); if (!user) redirect("/admin/users?error=unknown-user"); const [account] = await db .select({ id: minecraftAccounts.id, username: minecraftAccounts.username }) .from(minecraftAccounts) .where( and( eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt), ), ) .limit(1); if (!account) redirect(userPath(user.id, "error=unknown-account")); if (!user.firstName) redirect(userPath(user.id, "error=missing-name")); let nickname: string; try { nickname = await synchronizeNickname({ discordUserId: user.discordUserId, firstName: user.firstName, minecraftUsername: account.username, }); } catch { await recordSyncFailure(admin, user.id, "set-primary-account"); redirect(userPath(user.id, "error=discord-update")); } await db.transaction(async (tx) => { await tx .update(minecraftAccounts) .set({ isPrimary: false, updatedAt: new Date() }) .where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt))); await tx .update(minecraftAccounts) .set({ isPrimary: true, updatedAt: new Date() }) .where(eq(minecraftAccounts.id, account.id)); }); await recordAdminEvent( admin, user.id, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId: account.id, username: account.username, nickname }, ); await recordAdminEvent( admin, user.id, "games.minecraft.account-manager.discord.nickname.updated", { nickname, operation: "set-primary-account" }, ); redirect(userPath(user.id, "saved=primary")); } export async function removeUserMinecraftAccount(formData: FormData) { const admin = await requireAdminSession(); const userId = String(formData.get("userId") ?? ""); const accountId = String(formData.get("accountId") ?? ""); const user = await targetUser(userId); if (!user) redirect("/admin/users?error=unknown-user"); const [account] = await db .select({ id: minecraftAccounts.id, username: minecraftAccounts.username, isPrimary: minecraftAccounts.isPrimary }) .from(minecraftAccounts) .where( and( eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt), ), ) .limit(1); if (!account) redirect(userPath(user.id, "error=unknown-account")); if (account.isPrimary && !user.firstName) { redirect(userPath(user.id, "error=missing-name")); } const [replacement] = account.isPrimary ? await db .select({ id: minecraftAccounts.id, username: minecraftAccounts.username }) .from(minecraftAccounts) .where( and( eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt), ), ) .limit(1) : [undefined]; let nickname: string | null = null; if (account.isPrimary && user.firstName) { try { nickname = await synchronizeNickname({ discordUserId: user.discordUserId, firstName: user.firstName, minecraftUsername: replacement?.username ?? null, }); } catch { await recordSyncFailure(admin, user.id, "remove-primary-account"); redirect(userPath(user.id, "error=discord-update")); } } await db.transaction(async (tx) => { await tx .update(minecraftAccounts) .set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }) .where(eq(minecraftAccounts.id, account.id)); if (replacement) { await tx .update(minecraftAccounts) .set({ isPrimary: true, updatedAt: new Date() }) .where(eq(minecraftAccounts.id, replacement.id)); } }); await recordAdminEvent( admin, user.id, "games.minecraft.account-manager.minecraft-account.removed", { accountId: account.id, username: account.username, replacementAccountId: replacement?.id ?? null, nickname, }, ); if (nickname) { await recordAdminEvent( admin, user.id, "games.minecraft.account-manager.discord.nickname.updated", { nickname, operation: "remove-primary-account" }, ); } redirect(userPath(user.id, "saved=account-removed")); } export async function synchronizeUserNickname(formData: FormData) { const admin = await requireAdminSession(); const userId = String(formData.get("userId") ?? ""); const user = await targetUser(userId); if (!user) redirect("/admin/users?error=unknown-user"); if (!user.firstName) redirect(userPath(user.id, "error=missing-name")); const minecraftUsername = await primaryUsername(user.id); let nickname: string; try { nickname = await synchronizeNickname({ discordUserId: user.discordUserId, firstName: user.firstName, minecraftUsername, }); } catch { await recordSyncFailure(admin, user.id, "manual-sync"); redirect(userPath(user.id, "error=discord-update")); } await recordAdminEvent( admin, user.id, "games.minecraft.account-manager.discord.nickname.updated", { nickname, operation: "manual-sync" }, ); redirect(userPath(user.id, "saved=nickname")); }