feat(admin): add user account management

This commit is contained in:
dmg
2026-08-01 14:16:46 -04:00
parent c5de0a1810
commit 10554eeaff
10 changed files with 690 additions and 6 deletions
@@ -0,0 +1,170 @@
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull, or } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/database";
import {
addUserMinecraftAccount,
removeUserMinecraftAccount,
setUserPrimaryAccount,
synchronizeUserNickname,
updateUserName,
} 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.",
"mojang-unavailable": "Mojang profile lookup is unavailable. No account was added.",
"already-registered": "That Minecraft account is already actively registered.",
"unknown-account": "That account is no longer active for this user.",
"missing-name": "Set the user's name before synchronizing Discord.",
"discord-update": "Discord rejected the nickname update. No requested profile change was saved.",
};
const savedMessages: Record<string, string> = {
name: "Name and Discord nickname updated.",
"account-added": "Minecraft account added.",
"account-removed": "Minecraft account removed.",
primary: "Primary account and Discord nickname updated.",
nickname: "Discord nickname synchronized.",
};
export default async function AdminUserPage({
params,
searchParams,
}: {
params: Promise<{ userId: string }>;
searchParams: Promise<{ error?: string; saved?: string; unverified?: string }>;
}) {
const { userId } = await params;
const query = await searchParams;
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) notFound();
const [accounts, recentEvents, 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(events)
.where(or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id)))
.orderBy(desc(events.time))
.limit(30),
db
.select()
.from(ipObservations)
.where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt))
.limit(20),
]);
const primary = accounts.find((account) => account.isPrimary);
const nickname = user.firstName
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
: null;
return (
<main className="mx-auto max-w-6xl px-6 py-12">
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/users"> All users</Link>
<header className="mt-7 flex flex-col gap-6 border-b border-line pb-8 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">User record</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? "Name needed"}</h1>
<p className="mt-3 font-mono text-xs text-muted">@{user.discordUsername} · {user.discordUserId}</p>
</div>
<div className="border-l-2 border-accent pl-5">
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Expected Discord nickname</p>
<p className="mt-2 font-display text-xl font-black">{nickname ?? "Set a first name"}</p>
{nickname && <form action={synchronizeUserNickname} className="mt-3"><input name="userId" type="hidden" value={user.id} /><button className="font-mono text-[9px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Synchronize now</button></form>}
</div>
</header>
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">{errorMessages[query.error] ?? "The requested operation failed."}</p>}
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">{savedMessages[query.saved] ?? "Changes saved."}</p>}
<div className="mt-10 grid gap-10 lg:grid-cols-[1.3fr_0.7fr]">
<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-[9px] 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">
<span className="font-mono text-lg font-bold">{account.username}</span>
{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" : "Unverified override"}</span>
</div>
<p className="mt-2 break-all font-mono text-[10px] text-muted">{account.minecraftUuid ?? "No UUID recorded"}</p>
</div>
<div className="flex items-center gap-4">
{!account.isPrimary && <form action={setUserPrimaryAccount}><input name="userId" type="hidden" value={user.id} /><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" type="submit">Make primary</button></form>}
<details className="relative">
<summary className="cursor-pointer list-none font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4">Remove</summary>
<form action={removeUserMinecraftAccount} className="absolute right-0 z-10 mt-2 w-56 border border-accent bg-panel p-4 shadow-[5px_5px_0_var(--color-accent)]">
<input name="userId" type="hidden" value={user.id} /><input name="accountId" type="hidden" value={account.id} />
<p className="text-xs leading-5">Remove {account.username}? This immediately blocks it from joining.</p>
<button className="mt-3 bg-accent px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Confirm removal</button>
</form>
</details>
</div>
</article>
))}
{!accounts.length && <p className="py-7 text-sm text-muted">No active Minecraft accounts.</p>}
</div>
{query.unverified ? (
<form action={addUserMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
<input name="userId" type="hidden" value={user.id} /><input name="username" type="hidden" value={query.unverified} /><input name="forceUnverified" type="hidden" value="yes" />
<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">Only override this when you have independently confirmed the spelling.</p>
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas" type="submit">Add unverified account</button>
<a className="ml-5 font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Cancel</a>
</form>
) : (
<form action={addUserMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
<input name="userId" type="hidden" value={user.id} />
<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-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Verify and add</button>
</form>
)}
</section>
<section>
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Audit trail</p>
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Recent events</h2>
<div className="divide-y divide-line">
{recentEvents.map((event) => <div className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><span className="font-mono text-xs font-bold">{event.type}</span><span className="font-mono text-[9px] text-muted">{event.time.toISOString()}</span></div>)}
{!recentEvents.length && <p className="py-6 text-sm text-muted">No events recorded for this user.</p>}
</div>
</section>
</div>
<aside className="space-y-8">
<form action={updateUserName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<input name="userId" type="hidden" value={user.id} />
<p className="font-mono text-[9px] 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">First name</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 />
<p className="mt-3 text-xs leading-5 text-muted">Saving also updates the Discord guild nickname.</p>
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" type="submit">Save and synchronize</button>
</form>
<section className="border border-line bg-panel p-6">
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p>
<div className="mt-4 divide-y divide-line">
{observations.map((observation) => <div className="py-3" key={observation.id}><p className="font-mono text-xs">{observation.ipAddress}</p><p className="mt-1 font-mono text-[9px] text-muted">{observation.source} · {observation.observedAt.toISOString()}</p></div>)}
{!observations.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
</div>
</section>
</aside>
</div>
</main>
);
}