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
@@ -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>
);
}
+48
View File
@@ -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>
);
}