feat(admin): add user account management
This commit is contained in:
@@ -63,6 +63,7 @@ The token is displayed once and stored only as a SHA-256 hash.
|
|||||||
|
|
||||||
- PostgreSQL and Drizzle ORM
|
- PostgreSQL and Drizzle ORM
|
||||||
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
||||||
|
- Admin user search, account management, primary-account changes, and Discord nickname synchronization
|
||||||
- Deployment-managed Discord guild ID and invite URL
|
- Deployment-managed Discord guild ID and invite URL
|
||||||
- discord.js bot with `/register` and `/account`
|
- discord.js bot with `/register` and `/account`
|
||||||
- Java Edition online-mode accounts only
|
- Java Edition online-mode accounts only
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { appSettings } from "@minecraft-account-manager/database";
|
import { appSettings } from "@minecraft-account-manager/database";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
|
||||||
export async function saveDiscordSettings(formData: FormData) {
|
export async function saveDiscordSettings(formData: FormData) {
|
||||||
const session = await getServerSession(adminAuthOptions);
|
await requireAdminSession();
|
||||||
const roles = (session?.user as { roles?: string[] } | undefined)?.roles ?? [];
|
|
||||||
if (!session || !roles.includes(requiredAdminRole)) redirect("/admin/login");
|
|
||||||
|
|
||||||
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
|
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default async function AdminConsoleLayout({ children }: { children: React
|
|||||||
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">Blocklist / Ops</Link>
|
<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">
|
<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">Settings</Link>
|
||||||
|
<Link className="hover:text-accent" href="/admin/users">Users</Link>
|
||||||
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<AdminSignOutButton />
|
<AdminSignOutButton />
|
||||||
|
|||||||
@@ -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 3–16 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 couldn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatManagedDiscordNickname,
|
||||||
|
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 { recordAdminEvent } from "@/lib/audit";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
|
||||||
|
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function userPath(userId: string, query?: string) {
|
||||||
|
return `/admin/users/${encodeURIComponent(userId)}${query ? `?${query}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function targetUser(userId: string) {
|
||||||
|
if (!UUID_PATTERN.test(userId)) return null;
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
return user ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function primaryUsername(userId: string) {
|
||||||
|
const [account] = await db
|
||||||
|
.select({ username: minecraftAccounts.username })
|
||||||
|
.from(minecraftAccounts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(minecraftAccounts.userId, userId),
|
||||||
|
eq(minecraftAccounts.isPrimary, true),
|
||||||
|
isNull(minecraftAccounts.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return account?.username ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synchronizeNickname(input: {
|
||||||
|
discordUserId: string;
|
||||||
|
firstName: string;
|
||||||
|
minecraftUsername: string | null;
|
||||||
|
}) {
|
||||||
|
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||||
|
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||||
|
if (!guildId || !botToken) throw new Error("Discord nickname updates are not configured");
|
||||||
|
|
||||||
|
const nickname = formatManagedDiscordNickname(input.firstName, input.minecraftUsername);
|
||||||
|
await updateGuildNickname({
|
||||||
|
guildId,
|
||||||
|
discordUserId: input.discordUserId,
|
||||||
|
nickname,
|
||||||
|
botToken,
|
||||||
|
});
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordSyncFailure(
|
||||||
|
admin: Awaited<ReturnType<typeof requireAdminSession>>,
|
||||||
|
userId: string,
|
||||||
|
operation: string,
|
||||||
|
) {
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
userId,
|
||||||
|
"games.minecraft.account-manager.discord.nickname.update-failed",
|
||||||
|
{ operation },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserName(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
const firstName = String(formData.get("firstName") ?? "").trim();
|
||||||
|
const user = await targetUser(userId);
|
||||||
|
|
||||||
|
if (!user) redirect("/admin/users?error=unknown-user");
|
||||||
|
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
|
||||||
|
redirect(userPath(user.id, "error=invalid-name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const minecraftUsername = await primaryUsername(user.id);
|
||||||
|
let nickname: string;
|
||||||
|
try {
|
||||||
|
nickname = await synchronizeNickname({
|
||||||
|
discordUserId: user.discordUserId,
|
||||||
|
firstName,
|
||||||
|
minecraftUsername,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await recordSyncFailure(admin, user.id, "update-first-name");
|
||||||
|
redirect(userPath(user.id, "error=discord-update"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
user.id,
|
||||||
|
"games.minecraft.account-manager.user.first-name.updated",
|
||||||
|
{ firstName, nickname },
|
||||||
|
);
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
user.id,
|
||||||
|
"games.minecraft.account-manager.discord.nickname.updated",
|
||||||
|
{ nickname, operation: "update-first-name" },
|
||||||
|
);
|
||||||
|
redirect(userPath(user.id, "saved=name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addUserMinecraftAccount(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
const requestedUsername = String(formData.get("username") ?? "").trim();
|
||||||
|
const forceUnverified = formData.get("forceUnverified") === "yes";
|
||||||
|
const user = await targetUser(userId);
|
||||||
|
|
||||||
|
if (!user) redirect("/admin/users?error=unknown-user");
|
||||||
|
if (!USERNAME_PATTERN.test(requestedUsername)) {
|
||||||
|
redirect(userPath(user.id, "error=invalid-username"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile: Awaited<ReturnType<typeof lookupJavaProfile>>;
|
||||||
|
try {
|
||||||
|
profile = await lookupJavaProfile(requestedUsername);
|
||||||
|
} catch {
|
||||||
|
redirect(userPath(user.id, "error=mojang-unavailable"));
|
||||||
|
}
|
||||||
|
if (!profile && !forceUnverified) {
|
||||||
|
redirect(userPath(user.id, `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 account: { id: string } | undefined;
|
||||||
|
try {
|
||||||
|
[account] = 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,
|
||||||
|
})
|
||||||
|
.returning({ id: minecraftAccounts.id });
|
||||||
|
} catch {
|
||||||
|
redirect(userPath(user.id, "error=already-registered"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = profile?.username ?? requestedUsername;
|
||||||
|
let nickname: string | null = null;
|
||||||
|
if (!existing && user.firstName && account) {
|
||||||
|
try {
|
||||||
|
nickname = await synchronizeNickname({
|
||||||
|
discordUserId: user.discordUserId,
|
||||||
|
firstName: user.firstName,
|
||||||
|
minecraftUsername: username,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await db.delete(minecraftAccounts).where(eq(minecraftAccounts.id, account.id));
|
||||||
|
await recordSyncFailure(admin, user.id, "add-first-account");
|
||||||
|
redirect(userPath(user.id, "error=discord-update"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
user.id,
|
||||||
|
"games.minecraft.account-manager.minecraft-account.added",
|
||||||
|
{
|
||||||
|
accountId: account?.id,
|
||||||
|
username,
|
||||||
|
minecraftUuid: profile?.uuid ?? null,
|
||||||
|
validationStatus: profile ? "verified" : "user_confirmed",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (nickname) {
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
user.id,
|
||||||
|
"games.minecraft.account-manager.discord.nickname.updated",
|
||||||
|
{ nickname, operation: "add-first-account" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
redirect(userPath(user.id, "saved=account-added"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserPrimaryAccount(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
const accountId = String(formData.get("accountId") ?? "");
|
||||||
|
const user = await targetUser(userId);
|
||||||
|
if (!user) redirect("/admin/users?error=unknown-user");
|
||||||
|
|
||||||
|
const [account] = await db
|
||||||
|
.select({ id: minecraftAccounts.id, username: minecraftAccounts.username })
|
||||||
|
.from(minecraftAccounts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(minecraftAccounts.id, accountId),
|
||||||
|
eq(minecraftAccounts.userId, user.id),
|
||||||
|
isNull(minecraftAccounts.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!account) redirect(userPath(user.id, "error=unknown-account"));
|
||||||
|
if (!user.firstName) redirect(userPath(user.id, "error=missing-name"));
|
||||||
|
|
||||||
|
let nickname: string;
|
||||||
|
try {
|
||||||
|
nickname = await synchronizeNickname({
|
||||||
|
discordUserId: user.discordUserId,
|
||||||
|
firstName: user.firstName,
|
||||||
|
minecraftUsername: account.username,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await recordSyncFailure(admin, user.id, "set-primary-account");
|
||||||
|
redirect(userPath(user.id, "error=discord-update"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
user.id,
|
||||||
|
"games.minecraft.account-manager.minecraft-account.primary-changed",
|
||||||
|
{ accountId: account.id, username: account.username, nickname },
|
||||||
|
);
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
user.id,
|
||||||
|
"games.minecraft.account-manager.discord.nickname.updated",
|
||||||
|
{ nickname, operation: "set-primary-account" },
|
||||||
|
);
|
||||||
|
redirect(userPath(user.id, "saved=primary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeUserMinecraftAccount(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
const accountId = String(formData.get("accountId") ?? "");
|
||||||
|
const user = await targetUser(userId);
|
||||||
|
if (!user) redirect("/admin/users?error=unknown-user");
|
||||||
|
|
||||||
|
const [account] = await db
|
||||||
|
.select({ id: minecraftAccounts.id, username: minecraftAccounts.username, isPrimary: minecraftAccounts.isPrimary })
|
||||||
|
.from(minecraftAccounts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(minecraftAccounts.id, accountId),
|
||||||
|
eq(minecraftAccounts.userId, user.id),
|
||||||
|
isNull(minecraftAccounts.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!account) redirect(userPath(user.id, "error=unknown-account"));
|
||||||
|
if (account.isPrimary && !user.firstName) {
|
||||||
|
redirect(userPath(user.id, "error=missing-name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [replacement] = account.isPrimary
|
||||||
|
? await db
|
||||||
|
.select({ id: minecraftAccounts.id, username: minecraftAccounts.username })
|
||||||
|
.from(minecraftAccounts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(minecraftAccounts.userId, user.id),
|
||||||
|
ne(minecraftAccounts.id, account.id),
|
||||||
|
isNull(minecraftAccounts.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
: [undefined];
|
||||||
|
|
||||||
|
let nickname: string | null = null;
|
||||||
|
if (account.isPrimary && user.firstName) {
|
||||||
|
try {
|
||||||
|
nickname = await synchronizeNickname({
|
||||||
|
discordUserId: user.discordUserId,
|
||||||
|
firstName: user.firstName,
|
||||||
|
minecraftUsername: replacement?.username ?? null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await recordSyncFailure(admin, user.id, "remove-primary-account");
|
||||||
|
redirect(userPath(user.id, "error=discord-update"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(minecraftAccounts)
|
||||||
|
.set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() })
|
||||||
|
.where(eq(minecraftAccounts.id, account.id));
|
||||||
|
if (replacement) {
|
||||||
|
await tx
|
||||||
|
.update(minecraftAccounts)
|
||||||
|
.set({ isPrimary: true, updatedAt: new Date() })
|
||||||
|
.where(eq(minecraftAccounts.id, replacement.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
user.id,
|
||||||
|
"games.minecraft.account-manager.minecraft-account.removed",
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
username: account.username,
|
||||||
|
replacementAccountId: replacement?.id ?? null,
|
||||||
|
nickname,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (nickname) {
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
user.id,
|
||||||
|
"games.minecraft.account-manager.discord.nickname.updated",
|
||||||
|
{ nickname, operation: "remove-primary-account" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
redirect(userPath(user.id, "saved=account-removed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function synchronizeUserNickname(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
const user = await targetUser(userId);
|
||||||
|
if (!user) redirect("/admin/users?error=unknown-user");
|
||||||
|
if (!user.firstName) redirect(userPath(user.id, "error=missing-name"));
|
||||||
|
|
||||||
|
const minecraftUsername = await primaryUsername(user.id);
|
||||||
|
let nickname: string;
|
||||||
|
try {
|
||||||
|
nickname = await synchronizeNickname({
|
||||||
|
discordUserId: user.discordUserId,
|
||||||
|
firstName: user.firstName,
|
||||||
|
minecraftUsername,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await recordSyncFailure(admin, user.id, "manual-sync");
|
||||||
|
redirect(userPath(user.id, "error=discord-update"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAdminEvent(
|
||||||
|
admin,
|
||||||
|
user.id,
|
||||||
|
"games.minecraft.account-manager.discord.nickname.updated",
|
||||||
|
{ nickname, operation: "manual-sync" },
|
||||||
|
);
|
||||||
|
redirect(userPath(user.id, "saved=nickname"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||||
|
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
|
||||||
|
export default async function AdminUsersPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ q?: string; error?: string }>;
|
||||||
|
}) {
|
||||||
|
const query = await searchParams;
|
||||||
|
const search = query.q?.trim().slice(0, 100) ?? "";
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
const where = search
|
||||||
|
? or(
|
||||||
|
ilike(users.firstName, pattern),
|
||||||
|
ilike(users.discordUsername, pattern),
|
||||||
|
eq(users.discordUserId, search),
|
||||||
|
sql`exists (
|
||||||
|
select 1 from ${minecraftAccounts}
|
||||||
|
where ${minecraftAccounts.userId} = ${users.id}
|
||||||
|
and ${minecraftAccounts.deletedAt} is null
|
||||||
|
and (
|
||||||
|
${minecraftAccounts.username} ilike ${pattern}
|
||||||
|
or ${minecraftAccounts.minecraftUuid} = ${search.toLowerCase()}
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
firstName: users.firstName,
|
||||||
|
discordUsername: users.discordUsername,
|
||||||
|
discordUserId: users.discordUserId,
|
||||||
|
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||||
|
primaryUsername: minecraftAccounts.username,
|
||||||
|
accountCount: sql<number>`(
|
||||||
|
select count(*)::int from ${minecraftAccounts} account_count
|
||||||
|
where account_count.user_id = ${users.id}
|
||||||
|
and account_count.deleted_at is null
|
||||||
|
)`,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(
|
||||||
|
minecraftAccounts,
|
||||||
|
and(
|
||||||
|
eq(minecraftAccounts.userId, users.id),
|
||||||
|
eq(minecraftAccounts.isPrimary, true),
|
||||||
|
isNull(minecraftAccounts.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(users.firstName, users.discordUsername)
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
|
<div className="flex flex-col gap-6 border-b border-line pb-7 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Identity registry</p>
|
||||||
|
<h1 className="mt-4 font-display text-5xl font-black uppercase">Users</h1>
|
||||||
|
</div>
|
||||||
|
<form className="flex w-full max-w-md gap-2" method="get">
|
||||||
|
<input
|
||||||
|
className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-xs outline-none focus:border-accent"
|
||||||
|
defaultValue={search}
|
||||||
|
name="q"
|
||||||
|
placeholder="Name, Discord ID, player, or UUID"
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">The requested user could not be found.</p>}
|
||||||
|
|
||||||
|
<div className="mt-8 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">User</th><th className="p-4">Discord</th><th className="p-4">Primary</th><th className="p-4">Accounts</th><th className="p-4">Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line">
|
||||||
|
{results.map((user) => (
|
||||||
|
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
|
||||||
|
<td className="p-4"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></td>
|
||||||
|
<td className="p-4"><div className="font-mono text-xs">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
|
||||||
|
<td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td>
|
||||||
|
<td className="p-4 font-mono text-xs">{user.accountCount}</td>
|
||||||
|
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!results.length && <tr><td className="p-8 text-muted" colSpan={5}>No users match that search.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 font-mono text-[9px] uppercase tracking-widest text-muted">Showing up to 100 users</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,23 @@ import { db } from "@/lib/database";
|
|||||||
|
|
||||||
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
|
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
|
||||||
|
|
||||||
|
export async function recordAdminEvent(
|
||||||
|
admin: { email: string | null; name: string | null },
|
||||||
|
targetUserId: 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/admin",
|
||||||
|
subject: `user/${targetUserId}`,
|
||||||
|
ipAddress: ipAddress ?? undefined,
|
||||||
|
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function recordUserEvent(
|
export async function recordUserEvent(
|
||||||
user: { id: string },
|
user: { id: string },
|
||||||
type: string,
|
type: string,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { adminAuthOptions, requiredAdminRole } from "./admin-auth";
|
||||||
|
|
||||||
|
export async function requireAdminSession() {
|
||||||
|
const session = await getServerSession(adminAuthOptions);
|
||||||
|
const roles = (session?.user as { roles?: string[] } | undefined)?.roles ?? [];
|
||||||
|
if (!session || !roles.includes(requiredAdminRole)) redirect("/admin/login");
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: session.user?.email ?? null,
|
||||||
|
name: session.user?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,6 +38,14 @@ export function formatDiscordNickname(firstName: string, minecraftUsername: stri
|
|||||||
return `${shortenedName}${suffix}`;
|
return `${shortenedName}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatManagedDiscordNickname(
|
||||||
|
firstName: string,
|
||||||
|
minecraftUsername: string | null,
|
||||||
|
) {
|
||||||
|
if (minecraftUsername) return formatDiscordNickname(firstName, minecraftUsername);
|
||||||
|
return [...firstName.trim()].slice(0, DISCORD_NICKNAME_LIMIT).join("").trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateGuildNickname(
|
export async function updateGuildNickname(
|
||||||
input: {
|
input: {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { formatDiscordNickname, lookupJavaProfile } from "../src/index";
|
import { formatDiscordNickname, formatManagedDiscordNickname, lookupJavaProfile } from "../src/index";
|
||||||
|
|
||||||
describe("Java Edition profiles", () => {
|
describe("Java Edition profiles", () => {
|
||||||
it("returns Mojang's canonical UUID and username", async () => {
|
it("returns Mojang's canonical UUID and username", async () => {
|
||||||
@@ -27,4 +27,9 @@ describe("Java Edition profiles", () => {
|
|||||||
);
|
);
|
||||||
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
|
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the user's name when an admin removes their final account", () => {
|
||||||
|
expect(formatManagedDiscordNickname("Alexandria Catherine", null)).toBe("Alexandria Catherine");
|
||||||
|
expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null)).toHaveLength(32);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user