feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"use server";
|
||||
|
||||
import { lookupJavaProfile, updateGuildNickname, formatDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull } 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 saveFirstName(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("/welcome?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("/welcome/minecraft");
|
||||
}
|
||||
|
||||
export async function addFirstMinecraftAccount(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("/welcome/minecraft?error=invalid-format");
|
||||
}
|
||||
|
||||
const profile = await lookupJavaProfile(requestedUsername);
|
||||
if (!profile && !confirmed) {
|
||||
redirect(`/welcome/minecraft?unverified=${encodeURIComponent(requestedUsername)}`);
|
||||
}
|
||||
|
||||
const [existingAccount] = 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: !existingAccount,
|
||||
});
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (failed) redirect("/welcome/minecraft?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("/welcome/discord");
|
||||
}
|
||||
|
||||
export async function confirmInitialNickname() {
|
||||
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("/welcome/discord?error=not-configured");
|
||||
}
|
||||
|
||||
try {
|
||||
await updateGuildNickname({
|
||||
guildId,
|
||||
discordUserId: user.discordUserId,
|
||||
nickname: formatDiscordNickname(user.firstName, account.username),
|
||||
botToken,
|
||||
});
|
||||
} catch {
|
||||
redirect("/welcome/discord?error=discord-update");
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ onboardingCompletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
|
||||
nickname: formatDiscordNickname(user.firstName, account.username),
|
||||
onboardingCompleted: true,
|
||||
});
|
||||
redirect("/account");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { minecraftAccounts } from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/database";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { confirmInitialNickname } from "../actions";
|
||||
|
||||
export default async function DiscordStepPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||
const user = await requireCurrentUser("/welcome/discord");
|
||||
const query = await searchParams;
|
||||
if (!user.firstName) redirect("/welcome");
|
||||
|
||||
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);
|
||||
if (!account) redirect("/welcome/minecraft");
|
||||
const nickname = formatDiscordNickname(user.firstName, account.username);
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-canvas px-6 py-12 text-ink">
|
||||
<section className="w-full max-w-2xl border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)] sm:p-12">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Step 3 / 3 · Confirm identity</p>
|
||||
<h1 className="mt-5 font-display text-5xl font-black uppercase leading-none tracking-tight">One name everywhere.</h1>
|
||||
<p className="mt-6 text-lg leading-8 text-muted">Your Discord nickname in the configured community server will become:</p>
|
||||
<div className="mt-7 border border-line bg-canvas px-6 py-5 font-display text-2xl font-black">{nickname}</div>
|
||||
{query.error && <p className="mt-5 border-l-2 border-accent pl-4 text-sm leading-6 text-accent">We couldn’t update Discord. Ask an admin to check the guild and bot nickname permissions, then retry.</p>}
|
||||
<form action={confirmInitialNickname} className="mt-8">
|
||||
<button className="border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas hover:bg-accent" type="submit">Confirm and update Discord</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { addFirstMinecraftAccount } from "../actions";
|
||||
|
||||
const errors: Record<string, string> = {
|
||||
"invalid-format": "Java usernames use 3–16 letters, numbers, or underscores.",
|
||||
"already-registered": "That Minecraft account is already registered.",
|
||||
};
|
||||
|
||||
export default async function MinecraftStepPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string; unverified?: string }>;
|
||||
}) {
|
||||
const user = await requireCurrentUser("/welcome/minecraft");
|
||||
if (!user.firstName) redirect("/welcome");
|
||||
const query = await searchParams;
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-canvas px-6 py-12 text-ink">
|
||||
<section className="w-full max-w-2xl border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)] sm:p-12">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Step 2 / 3 · Java Edition</p>
|
||||
<h1 className="mt-5 font-display text-5xl font-black uppercase leading-none tracking-tight">Add your player.</h1>
|
||||
<p className="mt-6 text-lg leading-8 text-muted">We’ll verify the username with Mojang and store its UUID for secure matching.</p>
|
||||
|
||||
{query.unverified ? (
|
||||
<form action={addFirstMinecraftAccount} className="mt-9 border-l-2 border-accent pl-6">
|
||||
<h2 className="font-display text-2xl font-black uppercase">We couldn’t verify “{query.unverified}”.</h2>
|
||||
<p className="mt-3 leading-7 text-muted">Check the spelling. If you’re certain it is correct, continue without a UUID. The server can associate it after a successful online-mode login.</p>
|
||||
<input name="username" type="hidden" value={query.unverified} />
|
||||
<input name="confirmUnverified" type="hidden" value="yes" />
|
||||
<div className="mt-6 flex flex-wrap gap-4">
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Yes, continue</button>
|
||||
<a className="border border-line px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider" href="/welcome/minecraft">Check spelling</a>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form action={addFirstMinecraftAccount} className="mt-9">
|
||||
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="username">Minecraft username</label>
|
||||
<input className="mt-3 w-full border border-line bg-canvas px-4 py-4 font-mono text-lg outline-none focus:border-accent" id="username" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" required autoFocus />
|
||||
{query.error && <p className="mt-3 text-sm text-accent">{errors[query.error] ?? "We could not add that account."}</p>}
|
||||
<button className="mt-7 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas hover:bg-accent" type="submit">Verify account</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { saveFirstName } from "./actions";
|
||||
|
||||
export default async function WelcomePage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||
const user = await requireCurrentUser("/welcome");
|
||||
const query = await searchParams;
|
||||
if (user.firstName) redirect("/welcome/minecraft");
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-canvas px-6 py-12 text-ink">
|
||||
<section className="w-full max-w-2xl border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)] sm:p-12">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Step 1 / 3 · Discord connected</p>
|
||||
<h1 className="mt-5 font-display text-5xl font-black uppercase leading-none tracking-tight">Welcome, {user.discordUsername}.</h1>
|
||||
<p className="mt-6 max-w-xl text-lg leading-8 text-muted">Let’s get started. What should we call you?</p>
|
||||
<form action={saveFirstName} className="mt-9">
|
||||
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">Your first name</label>
|
||||
<input className="mt-3 w-full border border-line bg-canvas px-4 py-4 text-lg outline-none focus:border-accent" id="firstName" maxLength={50} name="firstName" required autoFocus />
|
||||
{query.error && <p className="mt-3 text-sm text-accent">Enter a name between 1 and 50 characters.</p>}
|
||||
<button className="mt-7 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas hover:bg-accent" type="submit">Continue</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user