feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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 3–16 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 couldn’t 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { appSettings } from "@minecraft-account-manager/database";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function saveDiscordSettings(formData: FormData) {
|
||||
const session = await getServerSession(adminAuthOptions);
|
||||
const roles = (session?.user as { roles?: string[] } | undefined)?.roles ?? [];
|
||||
if (!session || !roles.includes(requiredAdminRole)) redirect("/admin/login");
|
||||
|
||||
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
|
||||
|
||||
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
|
||||
redirect("/admin?error=invalid-message");
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(appSettings)
|
||||
.values({
|
||||
id: "default",
|
||||
registrationMessage,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: appSettings.id,
|
||||
set: {
|
||||
registrationMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
redirect("/admin?saved=1");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { events } from "@minecraft-account-manager/database";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export default async function EventsPage() {
|
||||
const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvents ledger</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase">Recent events</h1>
|
||||
<div className="mt-10 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[760px] border-collapse text-left">
|
||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line text-xs">
|
||||
{recentEvents.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td className="whitespace-nowrap p-4 font-mono text-muted">{event.time.toISOString()}</td>
|
||||
<td className="p-4 font-mono font-bold">{event.type}</td>
|
||||
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
|
||||
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={4}>No events have been recorded.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { recordEvent } from "@minecraft-account-manager/database";
|
||||
import { headers } from "next/headers";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AdminSignOutButton } from "@/components/admin-sign-out-button";
|
||||
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export default async function AdminConsoleLayout({ children }: { children: ReactNode }) {
|
||||
const session = await getServerSession(adminAuthOptions);
|
||||
if (!session) redirect("/admin/login");
|
||||
|
||||
const roles = (session.user as typeof session.user & { roles?: string[] })?.roles ?? [];
|
||||
if (!roles.includes(requiredAdminRole)) redirect("/admin/login?error=forbidden");
|
||||
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
await recordEvent(db, {
|
||||
type: "games.minecraft.account-manager.ui.accessed",
|
||||
source: "/web/admin",
|
||||
subject: "admin-console",
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data: { adminEmail: session.user?.email ?? null },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-ink">
|
||||
<header className="border-b border-line bg-panel">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
|
||||
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">Blocklist / Ops</Link>
|
||||
<nav className="ml-auto mr-8 flex gap-5 font-mono text-[10px] font-bold uppercase tracking-widest">
|
||||
<Link className="hover:text-accent" href="/admin">Settings</Link>
|
||||
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||
</nav>
|
||||
<AdminSignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { appSettings } from "@minecraft-account-manager/database";
|
||||
import { db } from "@/lib/database";
|
||||
import { saveDiscordSettings } from "./actions";
|
||||
|
||||
export default async function AdminPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ saved?: string; error?: string }>;
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]">
|
||||
<section>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
|
||||
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
|
||||
<dl className="mt-7 space-y-2 font-mono text-[10px] uppercase tracking-wider text-muted">
|
||||
<div><dt className="inline font-bold text-ink">Guild:</dt> <dd className="inline">{process.env.DISCORD_GUILD_ID ? "configured" : "missing"}</dd></div>
|
||||
<div><dt className="inline font-bold text-ink">Invite:</dt> <dd className="inline">{process.env.DISCORD_INVITE_URL ? "configured" : "missing"}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
||||
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider">Settings saved</p>}
|
||||
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm">Check the highlighted configuration values and try again.</p>}
|
||||
|
||||
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
|
||||
<textarea
|
||||
className="mt-3 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
|
||||
defaultValue={message}
|
||||
id="registrationMessage"
|
||||
maxLength={500}
|
||||
minLength={10}
|
||||
name="registrationMessage"
|
||||
required
|
||||
/>
|
||||
|
||||
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AdminSignInButton } from "@/components/admin-sign-in-button";
|
||||
import { isAdminOidcConfigured } from "@/lib/auth/admin-auth";
|
||||
|
||||
export default async function AdminLoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { error } = await searchParams;
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-ink px-6 text-canvas">
|
||||
<section className="w-full max-w-md border border-[#4a4d46] bg-[#20221e] p-8 shadow-[10px_10px_0_#bc3f24]">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-signal">Restricted console</p>
|
||||
<h1 className="mt-5 font-display text-4xl font-black uppercase tracking-tight">Operator access</h1>
|
||||
<p className="mb-8 mt-4 leading-7 text-[#b7b8ae]">Authenticate through Keycloak. The configured admin realm role is required.</p>
|
||||
{error && <p className="mb-5 border-l-2 border-accent pl-4 text-sm text-[#efb5a8]">Your identity does not have access to this console.</p>}
|
||||
{isAdminOidcConfigured ? (
|
||||
<AdminSignInButton />
|
||||
) : (
|
||||
<p className="border border-[#5c5e56] p-4 font-mono text-xs leading-6 text-[#d8b46e]">OIDC is not configured. Add the Keycloak environment variables before signing in.</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { adminAuthOptions } from "@/lib/auth/admin-auth";
|
||||
|
||||
const handler = NextAuth(adminAuthOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,179 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||
import { velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
|
||||
import {
|
||||
appSettings,
|
||||
events,
|
||||
ipObservations,
|
||||
minecraftAccounts,
|
||||
pluginCredentials,
|
||||
pluginRequests,
|
||||
} from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
const MAX_CLOCK_SKEW_MS = 45_000;
|
||||
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authorization = request.headers.get("authorization") ?? "";
|
||||
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
|
||||
if (!token) return NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const parsed = velocityAccessRequestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ allowed: false, message: "Invalid access request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const occurredAt = new Date(input.occurredAt);
|
||||
if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) {
|
||||
return NextResponse.json({ allowed: false, message: "Expired access request" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [credential] = await db
|
||||
.select({ secretHash: pluginCredentials.secretHash })
|
||||
.from(pluginCredentials)
|
||||
.where(and(eq(pluginCredentials.serverId, input.serverId), isNull(pluginCredentials.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!credential || !verifyHashedToken(token, credential.secretHash)) {
|
||||
return NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const denialMessage = settings?.registrationMessage ?? DEFAULT_DENIAL_MESSAGE;
|
||||
|
||||
try {
|
||||
const decision = await db.transaction(async (tx) => {
|
||||
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
|
||||
await tx.insert(pluginRequests).values({
|
||||
requestId: input.requestId,
|
||||
serverId: input.serverId,
|
||||
receivedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000),
|
||||
});
|
||||
|
||||
let [account] = await tx
|
||||
.select({
|
||||
id: minecraftAccounts.id,
|
||||
userId: minecraftAccounts.userId,
|
||||
minecraftUuid: minecraftAccounts.minecraftUuid,
|
||||
username: minecraftAccounts.username,
|
||||
})
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(minecraftAccounts.minecraftUuid, input.minecraftUuid),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!account) {
|
||||
[account] = await tx
|
||||
.select({
|
||||
id: minecraftAccounts.id,
|
||||
userId: minecraftAccounts.userId,
|
||||
minecraftUuid: minecraftAccounts.minecraftUuid,
|
||||
username: minecraftAccounts.username,
|
||||
})
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
isNull(minecraftAccounts.minecraftUuid),
|
||||
sql`lower(${minecraftAccounts.username}) = lower(${input.username})`,
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
type: "games.minecraft.account-manager.game.login.denied",
|
||||
subject: `minecraft-account/${input.minecraftUuid}`,
|
||||
time: occurredAt,
|
||||
data: { username: input.username, reason: "not_registered" },
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
await tx.insert(ipObservations).values({
|
||||
source: "game",
|
||||
ipAddress: input.ipAddress,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
classification: "unknown",
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return { allowed: false as const, message: denialMessage };
|
||||
}
|
||||
|
||||
if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) {
|
||||
await tx
|
||||
.update(minecraftAccounts)
|
||||
.set({
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
lastVerifiedAt: occurredAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(minecraftAccounts.id, account.id));
|
||||
|
||||
if (account.username !== input.username) {
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
type: "games.minecraft.account-manager.minecraft-account.username-changed",
|
||||
subject: `minecraft-account/${account.id}`,
|
||||
time: occurredAt,
|
||||
actorUserId: account.userId,
|
||||
data: { previousUsername: account.username, username: input.username },
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.insert(ipObservations).values({
|
||||
userId: account.userId,
|
||||
minecraftAccountId: account.id,
|
||||
source: "game",
|
||||
ipAddress: input.ipAddress,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
classification: "unknown",
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
type: "games.minecraft.account-manager.game.login.allowed",
|
||||
subject: `minecraft-account/${account.id}`,
|
||||
time: occurredAt,
|
||||
actorUserId: account.userId,
|
||||
data: {
|
||||
username: input.username,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
previousUsername: account.username === input.username ? null : account.username,
|
||||
uuidBackfilled: account.minecraftUuid === null,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
|
||||
return { allowed: true as const, message: "Account approved." };
|
||||
});
|
||||
|
||||
return NextResponse.json(decision);
|
||||
} catch (error) {
|
||||
console.error("Velocity access decision failed", error);
|
||||
return NextResponse.json(
|
||||
{ allowed: false, message: denialMessage },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { hashToken, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
|
||||
import { sessions } from "@minecraft-account-manager/database";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function logout() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (token) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(sessions.tokenHash, hashToken(token)));
|
||||
}
|
||||
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
redirect("/");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { exchangeMagicLink, InvalidLoginCodeError, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
|
||||
import { createAuthRepository, ipObservations, recordEvent } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const code = request.nextUrl.searchParams.get("code") ?? "";
|
||||
|
||||
try {
|
||||
const result = await exchangeMagicLink(code, {
|
||||
repository: createAuthRepository(db),
|
||||
});
|
||||
const ipAddress = getClientIp(request.headers, process.env.TRUST_PROXY === "true");
|
||||
await Promise.all([
|
||||
recordEvent(db, {
|
||||
type: "games.minecraft.account-manager.auth.magic-link.consumed",
|
||||
source: "/web/auth/discord",
|
||||
subject: `user/${result.user.id}`,
|
||||
actorUserId: result.user.id,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data: { isNewUser: result.isNewUser },
|
||||
}),
|
||||
ipAddress
|
||||
? db.insert(ipObservations).values({
|
||||
userId: result.user.id,
|
||||
source: "web",
|
||||
ipAddress,
|
||||
classification: "unknown",
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
|
||||
const destination = result.user.firstName ? "/account" : "/welcome";
|
||||
const response = NextResponse.redirect(new URL(destination, request.url));
|
||||
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
expires: result.sessionExpiresAt,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidLoginCodeError) {
|
||||
return NextResponse.redirect(new URL("/auth/error", request.url));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoginErrorPage() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-canvas px-6 text-ink">
|
||||
<section className="max-w-lg border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Link rejected</p>
|
||||
<h1 className="mt-4 font-display text-4xl font-black uppercase tracking-tight">That gate key no longer works.</h1>
|
||||
<p className="mt-5 leading-7 text-muted">Login links expire after ten minutes and can only be used once. Return to Discord and run <strong>/account</strong> for a fresh link.</p>
|
||||
<Link className="mt-7 inline-block border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" href="/">Back home</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,15 @@ const steps = [
|
||||
["03", "Join the server", "Add your Java account and connect once approved."],
|
||||
] as const;
|
||||
|
||||
export default function HomePage() {
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ portal?: string }>;
|
||||
}) {
|
||||
const { portal } = await searchParams;
|
||||
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||
|
||||
return (
|
||||
<main className="relative min-h-screen overflow-hidden bg-canvas text-ink">
|
||||
<div className="terrain" aria-hidden="true" />
|
||||
@@ -22,6 +30,12 @@ export default function HomePage() {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{portal && (
|
||||
<div className="mt-8 border border-accent bg-panel px-5 py-4 font-mono text-xs leading-6 shadow-[5px_5px_0_var(--color-accent)]">
|
||||
The account portal starts in Discord. Join the server, then run <strong>/register</strong> or <strong>/account</strong> to receive your private sign-in link.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section id="top" className="grid flex-1 items-center gap-14 py-20 lg:grid-cols-[1.15fr_0.85fr] lg:py-24">
|
||||
<div>
|
||||
<p className="mb-7 font-mono text-xs font-semibold uppercase tracking-[0.3em] text-accent">
|
||||
@@ -57,6 +71,16 @@ export default function HomePage() {
|
||||
<span className="text-signal">></span> Open Discord and type <strong>/register</strong>
|
||||
<span className="cursor ml-1 inline-block h-4 w-2 bg-signal align-middle" />
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{inviteUrl ? (
|
||||
<a className="border border-ink bg-accent px-4 py-3 text-center font-mono text-[10px] font-bold uppercase tracking-widest text-canvas" href={inviteUrl} rel="noreferrer" target="_blank">Join Discord server</a>
|
||||
) : (
|
||||
<span className="border border-line px-4 py-3 text-center font-mono text-[10px] uppercase tracking-widest text-muted">Invite not configured</span>
|
||||
)}
|
||||
{guildId && (
|
||||
<a className="border border-ink px-4 py-3 text-center font-mono text-[10px] font-bold uppercase tracking-widest" href={`discord://-/channels/${guildId}`}>Open Discord app</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
export function AdminSignInButton() {
|
||||
return (
|
||||
<button
|
||||
className="w-full border border-ink bg-ink px-5 py-4 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas transition-transform hover:-translate-y-0.5"
|
||||
onClick={() => signIn("keycloak", { callbackUrl: "/admin" })}
|
||||
type="button"
|
||||
>
|
||||
Continue with SSO
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
export function AdminSignOutButton() {
|
||||
return (
|
||||
<button
|
||||
className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline decoration-line underline-offset-4 hover:text-ink"
|
||||
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
||||
type="button"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { ipObservations, recordEvent } from "@minecraft-account-manager/database";
|
||||
import { headers } from "next/headers";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
|
||||
|
||||
export async function recordUserEvent(
|
||||
user: { id: string },
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
return recordEvent(db, {
|
||||
type,
|
||||
source: "/web",
|
||||
subject: `user/${user.id}`,
|
||||
actorUserId: user.id,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordAuthenticatedUiAccess(
|
||||
user: { id: string },
|
||||
path: string,
|
||||
) {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
|
||||
await Promise.all([
|
||||
recordEvent(db, {
|
||||
type: UI_ACCESSED,
|
||||
source: "/web",
|
||||
subject: `user/${user.id}`,
|
||||
actorUserId: user.id,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data: { path },
|
||||
}),
|
||||
ipAddress
|
||||
? db.insert(ipObservations).values({
|
||||
userId: user.id,
|
||||
source: "web",
|
||||
ipAddress,
|
||||
classification: "unknown",
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { extractOidcRoles } from "@minecraft-account-manager/auth";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||
|
||||
const issuer = process.env.KEYCLOAK_ISSUER_URL?.trim() ?? "";
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID?.trim() ?? "";
|
||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET?.trim() ?? "";
|
||||
export const requiredAdminRole =
|
||||
process.env.KEYCLOAK_REQUIRED_ROLE?.trim() || "minecraft-account-manager-admin";
|
||||
|
||||
export const isAdminOidcConfigured = Boolean(issuer && clientId && clientSecret);
|
||||
|
||||
export const adminAuthOptions: NextAuthOptions = {
|
||||
secret: process.env.AUTH_SECRET,
|
||||
providers: isAdminOidcConfigured
|
||||
? [
|
||||
KeycloakProvider({
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorization: { params: { scope: "openid email profile" } },
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
pages: { signIn: "/admin/login" },
|
||||
session: { strategy: "jwt" },
|
||||
callbacks: {
|
||||
async signIn({ profile, account }) {
|
||||
if (!isAdminOidcConfigured) return false;
|
||||
return extractOidcRoles({
|
||||
clientId,
|
||||
profile,
|
||||
idToken: account?.id_token,
|
||||
accessToken: account?.access_token,
|
||||
}).includes(requiredAdminRole);
|
||||
},
|
||||
async jwt({ token, profile, account }) {
|
||||
if (profile || account?.id_token || account?.access_token) {
|
||||
token.roles = extractOidcRoles({
|
||||
clientId,
|
||||
profile,
|
||||
idToken: account?.id_token,
|
||||
accessToken: account?.access_token,
|
||||
});
|
||||
}
|
||||
token.roles ??= [];
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
(session.user as typeof session.user & { roles: string[] }).roles = Array.isArray(token.roles)
|
||||
? token.roles.filter((role): role is string => typeof role === "string")
|
||||
: [];
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { hashToken, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
|
||||
import { findUserBySessionToken } from "@minecraft-account-manager/database";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { recordAuthenticatedUiAccess } from "@/lib/audit";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function getCurrentUser() {
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
return findUserBySessionToken(db, hashToken(token));
|
||||
}
|
||||
|
||||
export async function requireCurrentUser(accessPath?: string) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect("/?portal=1");
|
||||
if (accessPath) await recordAuthenticatedUiAccess(user, accessPath);
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createDatabase } from "@minecraft-account-manager/database";
|
||||
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts";
|
||||
|
||||
const globalDatabase = globalThis as typeof globalThis & {
|
||||
accountManagerDatabase?: ReturnType<typeof createDatabase>;
|
||||
};
|
||||
|
||||
export const database =
|
||||
globalDatabase.accountManagerDatabase ?? createDatabase(databaseUrl);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalDatabase.accountManagerDatabase = database;
|
||||
}
|
||||
|
||||
export const db = database.db;
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import "next-auth/jwt";
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
roles?: string[];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user