Files
minecraft-account-manager/apps/web/src/app/admin/(console)/users/actions.ts
T
dmg d4afb71798
CI / validate (push) Successful in 4m16s
Release / release (push) Failing after 9m14s
feat(admin): streamline group management
2026-08-02 11:06:55 -04:00

436 lines
15 KiB
TypeScript

"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<ReturnType<typeof requireAdminSession>>,
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<ReturnType<typeof lookupJavaProfile>>;
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"));
}