Files
minecraft-account-manager/apps/web/src/app/account/actions.ts
T
dmg b7c0083647
CI / validate (push) Successful in 5m20s
Release / release (push) Successful in 6m56s
feat(portal): add SSR operations and exclusive groups
2026-08-01 19:21:23 -04:00

220 lines
9.3 KiB
TypeScript

"use server";
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
import { formatManagedDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
import { and, eq, isNull, ne } from "drizzle-orm";
import { redirect } from "next/navigation";
import { recordUserEvent } from "@/lib/audit";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { db } from "@/lib/database";
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
import { logger } from "@/lib/logger";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
type CurrentUser = Awaited<ReturnType<typeof requireCurrentUser>>;
type NicknameSyncResult = "updated" | "not-configured" | "failed";
async function synchronizeNickname(user: CurrentUser, nickname: string, operation: string): Promise<NicknameSyncResult> {
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!guildId || !botToken) return "not-configured";
try {
await updateGuildNickname({ guildId, discordUserId: user.discordUserId, nickname, botToken });
} catch (error) {
logger.error(
{ err: error, event: "account.discord_nickname_update_failed", operation },
"Failed to update the Discord guild nickname",
);
return "failed";
}
try {
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname,
operation,
});
} catch (error) {
logger.error(
{ err: error, event: "account.discord_nickname_audit_failed", operation },
"Discord nickname updated but its audit event could not be recorded",
);
}
return "updated";
}
function nicknameResultUrl(nickname: string, synchronization: NicknameSyncResult, additionalQuery?: string) {
const result = synchronization === "updated"
? `nicknameUpdated=${encodeURIComponent(nickname)}`
: `error=${synchronization === "not-configured" ? "nickname-not-configured" : "nickname-update-failed"}&nicknameExpected=${encodeURIComponent(nickname)}`;
return `/account?${additionalQuery ? `${additionalQuery}&` : ""}${result}`;
}
export async function updateFirstName(formData: FormData) {
const user = await requireCurrentUser();
const firstName = String(formData.get("firstName") ?? "").trim();
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
redirect("/account?error=invalid-name");
}
const [primary] = await db
.select({ username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)))
.limit(1);
const nickname = formatManagedDiscordNickname(firstName, primary?.username ?? null);
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
const synchronization = await synchronizeNickname(user, nickname, "update-first-name");
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
redirect(nicknameResultUrl(nickname, synchronization));
}
export async function addMinecraftAccount(formData: FormData) {
const user = await requireCurrentUser();
const requestedUsername = String(formData.get("username") ?? "").trim();
const confirmed = formData.get("confirmUnverified") === "yes";
if (!USERNAME_PATTERN.test(requestedUsername)) redirect("/account?error=invalid-username");
const network = await checkAccountAdditionNetwork();
if (!network.allowed) {
await recordUserEvent(
user,
network.reason === "blocked"
? "games.minecraft.account-manager.network.vpn-blocked"
: "games.minecraft.account-manager.network.classification-unavailable",
{
attemptedUsername: requestedUsername,
ipIntelligence: network.intelligence ? toAuditIpData(network.intelligence) : null,
},
);
redirect(`/account?error=${network.reason === "blocked" ? "vpn-blocked" : "ip-check-unavailable"}`);
}
const profile = await lookupJavaProfile(requestedUsername);
if (!profile && !confirmed) redirect(`/account?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);
const username = profile?.username ?? requestedUsername;
try {
await db.insert(minecraftAccounts).values({
userId: user.id,
minecraftUuid: profile?.uuid ?? null,
username,
validationStatus: profile ? "verified" : "user_confirmed",
lastVerifiedAt: profile ? new Date() : null,
isPrimary: !existing,
});
} catch {
redirect("/account?error=already-registered");
}
const nickname = !existing && user.firstName
? formatManagedDiscordNickname(user.firstName, username)
: null;
const synchronization = nickname
? await synchronizeNickname(user, nickname, "add-first-account")
: null;
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
username,
minecraftUuid: profile?.uuid ?? null,
validationStatus: profile ? "verified" : "user_confirmed",
});
redirect(nickname && synchronization
? nicknameResultUrl(nickname, synchronization, "added=1")
: "/account?added=1");
}
export async function setPrimaryAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const [requestedAccount] = 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 (!requestedAccount) redirect("/account?error=unknown-account");
if (!user.firstName) redirect("/account?error=nickname-not-configured");
const changed = await db.transaction(async (tx) => {
const [account] = await tx
.select({ id: minecraftAccounts.id })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.id, requestedAccount.id), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!account) return false;
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));
return true;
});
if (!changed) redirect("/account?error=unknown-account");
const nickname = formatManagedDiscordNickname(user.firstName, requestedAccount.username);
const synchronization = await synchronizeNickname(user, nickname, "set-primary-account");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", {
accountId: requestedAccount.id,
nickname,
});
redirect(nicknameResultUrl(nickname, synchronization));
}
export async function removeMinecraftAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const result = await db.transaction(async (tx) => {
const [account] = await tx
.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!account) return null;
await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
let replacementUsername: string | null = null;
if (account.isPrimary) {
const [replacement] = await tx
.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);
if (replacement) {
replacementUsername = replacement.username;
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id));
}
} else {
const [primary] = await tx
.select({ username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)))
.limit(1);
replacementUsername = primary?.username ?? null;
}
return { replacementUsername };
});
if (!result) redirect("/account?error=unknown-account");
const nickname = user.firstName
? formatManagedDiscordNickname(user.firstName, result.replacementUsername)
: null;
const synchronization = nickname
? await synchronizeNickname(user, nickname, "remove-account")
: null;
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId });
redirect(nickname && synchronization
? nicknameResultUrl(nickname, synchronization, "removed=1")
: "/account?removed=1");
}