371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
"use server";
|
|
|
|
import {
|
|
formatManagedDiscordNickname,
|
|
lookupJavaProfile,
|
|
updateGuildNickname,
|
|
} from "@minecraft-account-manager/minecraft";
|
|
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
|
import { and, eq, isNull, ne } from "drizzle-orm";
|
|
import { redirect } from "next/navigation";
|
|
import { recordAdminEvent } from "@/lib/audit";
|
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
|
import { db } from "@/lib/database";
|
|
|
|
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 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"));
|
|
}
|