feat(platform): add Discord onboarding and Velocity gate

This commit is contained in:
dmg
2026-08-01 13:45:18 -04:00
parent 9d305e5dc9
commit c5de0a1810
80 changed files with 4840 additions and 1572 deletions
+132
View File
@@ -0,0 +1,132 @@
"use server";
import { formatDiscordNickname, 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 { recordUserEvent } from "@/lib/audit";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
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");
}
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
redirect("/account?confirmNickname=1");
}
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 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);
let failed = false;
try {
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,
});
} catch {
failed = true;
}
if (failed) redirect("/account?error=already-registered");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
username: profile?.username ?? requestedUsername,
minecraftUuid: profile?.uuid ?? null,
validationStatus: profile ? "verified" : "user_confirmed",
});
redirect(existing ? "/account?added=1" : "/account?confirmNickname=1");
}
export async function setPrimaryAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const changed = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, accountId), 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");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId });
redirect("/account?confirmNickname=1");
}
export async function removeMinecraftAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const removed = 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 false;
await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
if (account.isPrimary) {
const [replacement] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (replacement) {
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id));
}
}
return true;
});
if (!removed) redirect("/account?error=unknown-account");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId });
redirect("/account?removed=1&confirmNickname=1");
}
export async function confirmDashboardNickname() {
const user = await requireCurrentUser();
const [account] = 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 guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!user.firstName || !account || !guildId || !botToken) redirect("/account?error=nickname-not-configured");
try {
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname: formatDiscordNickname(user.firstName, account.username),
botToken,
});
} catch {
redirect("/account?error=nickname-update-failed&confirmNickname=1");
}
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname: formatDiscordNickname(user.firstName, account.username),
});
redirect("/account?nicknameUpdated=1");
}
+148
View File
@@ -0,0 +1,148 @@
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
import { ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull } from "drizzle-orm";
import { logout } from "@/app/auth/actions";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
import {
addMinecraftAccount,
confirmDashboardNickname,
removeMinecraftAccount,
setPrimaryAccount,
updateFirstName,
} from "./actions";
const errorMessages: Record<string, string> = {
"invalid-name": "Enter a valid name between 1 and 50 characters.",
"invalid-username": "Java usernames use 316 letters, numbers, or underscores.",
"already-registered": "That Minecraft account is already registered.",
"unknown-account": "That account is no longer available.",
"nickname-not-configured": "Discord nickname updates are not configured.",
"nickname-update-failed": "Discord rejected the nickname update. An admin may need to adjust bot permissions.",
};
export default async function AccountPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
const user = await requireCurrentUser("/account");
const query = await searchParams;
const [accounts, observations] = await Promise.all([
db
.select()
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
db
.select()
.from(ipObservations)
.where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt))
.limit(20),
]);
const primary = accounts.find((account) => account.isPrimary);
const desiredNickname = user.firstName && primary
? formatDiscordNickname(user.firstName, primary.username)
: null;
return (
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
<section className="mx-auto max-w-6xl">
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Account registry</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? user.discordUsername}</h1>
</div>
<form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form>
</header>
{query.error && (
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">
{errorMessages[query.error] ?? "The requested change could not be completed."}
</p>
)}
{query.nicknameUpdated && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Discord nickname updated</p>}
{query.confirmNickname && desiredNickname && (
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
<div>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Confirm Discord change</p>
<p className="mt-2 text-sm text-muted">Your community nickname will become</p>
<p className="mt-1 font-display text-2xl font-black">{desiredNickname}</p>
</div>
<form action={confirmDashboardNickname} className="mt-5 sm:mt-0">
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Confirm update</button>
</form>
</section>
)}
<div className="mt-12 grid gap-10 lg:grid-cols-[1.35fr_0.65fr]">
<div className="space-y-10">
<section>
<div className="flex items-end justify-between border-b border-line pb-4">
<div><p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Whitelist identities</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Minecraft accounts</h2></div>
<span className="font-mono text-xs text-muted">{accounts.length} active</span>
</div>
<div className="divide-y divide-line">
{accounts.map((account) => (
<article className="grid gap-4 py-6 sm:grid-cols-[1fr_auto] sm:items-center" key={account.id}>
<div>
<div className="flex flex-wrap items-center gap-3">
<h3 className="font-mono text-lg font-bold">{account.username}</h3>
{account.isPrimary && <span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase tracking-widest text-canvas">Primary</span>}
<span className="border border-line px-2 py-1 font-mono text-[9px] uppercase tracking-wider text-muted">{account.validationStatus === "verified" ? "UUID verified" : "User confirmed"}</span>
</div>
<p className="mt-2 break-all font-mono text-[10px] text-muted">{account.minecraftUuid ?? "UUID will be learned at game login"}</p>
</div>
<div className="flex gap-4">
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>}
<form action={removeMinecraftAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4" type="submit">Remove</button></form>
</div>
</article>
))}
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
</div>
{query.unverified ? (
<form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
<h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {query.unverified}</h3>
<p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p>
<input name="username" type="hidden" value={query.unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button>
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
</form>
) : (
<form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
<input className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Add account</button>
</form>
)}
</section>
<section>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p>
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
{observations.length ? (
<div className="divide-y divide-line font-mono text-xs">
{observations.map((observation) => <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><span>{observation.ipAddress}</span><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>)}
</div>
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
</section>
</div>
<aside>
<form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label>
<input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required />
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Discord preview: <strong className="text-ink">{desiredNickname}</strong></p>}
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button>
</form>
</aside>
</div>
</section>
</main>
);
}