236 lines
18 KiB
TypeScript
236 lines
18 KiB
TypeScript
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
||
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||
import Link from "next/link";
|
||
import { notFound } from "next/navigation";
|
||
import { groupAccessAddresses } from "@/lib/access-address-groups";
|
||
import { db } from "@/lib/database";
|
||
import { discordIdentity } from "@/lib/discord-identity";
|
||
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
||
import { eventIpSummary } from "@/lib/event-ip-summary";
|
||
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 const dynamic = "force-dynamic";
|
||
|
||
function queryValues(value: string | string[] | undefined) {
|
||
return Array.isArray(value) ? value : value ? [value] : [];
|
||
}
|
||
|
||
export default async function AdminUserPage({
|
||
params,
|
||
searchParams,
|
||
}: {
|
||
params: Promise<{ userId: string }>;
|
||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||
}) {
|
||
const { userId } = await params;
|
||
const query = await searchParams;
|
||
const error = queryValues(query.error)[0];
|
||
const saved = queryValues(query.saved)[0];
|
||
const unverified = queryValues(query.unverified)[0];
|
||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||
if (!user) notFound();
|
||
|
||
const userEventCondition = or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id));
|
||
const eventTypeRows = await db.select({ type: events.type }).from(events).where(userEventCondition).groupBy(events.type).orderBy(events.type);
|
||
const availableEventTypes = eventTypeRows.map((row) => row.type);
|
||
const selectedCategory = normalizeEventCategory(queryValues(query.eventCategory)[0]);
|
||
const selectedEventTypes = normalizeSelectedEventTypes(query.eventType, availableEventTypes);
|
||
const categoryTypes = selectedCategory === "all"
|
||
? availableEventTypes
|
||
: availableEventTypes.filter((type) => eventCategory(type) === selectedCategory);
|
||
const filteredEventTypes = selectedEventTypes.length
|
||
? selectedEventTypes.filter((type) => categoryTypes.includes(type))
|
||
: categoryTypes;
|
||
|
||
const recentEventsQuery = filteredEventTypes.length
|
||
? db.select().from(events).where(and(userEventCondition, inArray(events.type, filteredEventTypes))).orderBy(desc(events.time)).limit(30)
|
||
: Promise.resolve([]);
|
||
|
||
const [accounts, recentEvents, observations, discord, availableGroups] = await Promise.all([
|
||
db
|
||
.select()
|
||
.from(minecraftAccounts)
|
||
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
||
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
||
recentEventsQuery,
|
||
db
|
||
.select()
|
||
.from(ipObservations)
|
||
.where(eq(ipObservations.userId, user.id))
|
||
.orderBy(desc(ipObservations.observedAt))
|
||
.limit(100),
|
||
discordIdentity(user),
|
||
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault })
|
||
.from(groups)
|
||
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
||
.orderBy(desc(groups.isDefault), groups.name),
|
||
]);
|
||
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
||
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
||
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
||
const addressGroups = groupAccessAddresses(
|
||
observations.map((observation) => ({ ...observation, intelligence: null })),
|
||
);
|
||
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>
|
||
<dl className="mt-4 grid gap-x-8 gap-y-2 font-mono text-[10px] text-muted sm:grid-cols-2">
|
||
<div><dt className="uppercase tracking-wider">Discord name</dt><dd className="mt-1 text-xs text-ink">{discord.globalName ?? discord.username}</dd></div>
|
||
<div><dt className="uppercase tracking-wider">Discord username</dt><dd className="mt-1 text-xs text-ink">@{discord.username}</dd></div>
|
||
<div><dt className="uppercase tracking-wider">Guild nickname</dt><dd className="mt-1 text-xs text-ink">{discord.nickname ?? "No guild nickname"}</dd></div>
|
||
<div><dt className="uppercase tracking-wider">Discord ID</dt><dd className="mt-1 break-all text-xs text-ink">{discord.id}</dd></div>
|
||
</dl>
|
||
</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>
|
||
|
||
{error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[error] ?? "The requested operation failed."}</p>}
|
||
{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" role="status">{savedMessages[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>
|
||
|
||
{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={unverified} /><input name="forceUnverified" type="hidden" value="yes" />
|
||
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{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 aria-label="Minecraft username" 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 font-display text-3xl font-black uppercase">Recent events</h2>
|
||
<form className="mt-4 grid gap-4 border-y border-line bg-panel p-4 sm:grid-cols-2" method="get">
|
||
<label className="font-mono text-[10px] font-bold uppercase" htmlFor="user-event-category">View<select className="mt-2 block w-full border border-line bg-canvas p-2 font-sans text-sm font-normal normal-case" defaultValue={selectedCategory} id="user-event-category" name="eventCategory">{eventCategoryValues.map((value) => <option key={value} value={value}>{value === "all" ? "All activity" : value}</option>)}</select></label>
|
||
<fieldset><legend className="font-mono text-[10px] font-bold uppercase">Types</legend><details className="mt-2 border border-line bg-canvas p-2"><summary className="cursor-pointer font-mono text-[9px] underline">{selectedEventTypes.length ? `${selectedEventTypes.length} selected` : "All types"}</summary><div className="mt-3 max-h-44 space-y-2 overflow-y-auto">{availableEventTypes.map((type) => <label className="flex items-start gap-2 font-mono text-[9px]" key={type}><input className="mt-0.5 size-4" defaultChecked={selectedEventTypes.includes(type)} name="eventType" type="checkbox" value={type} /><span className="break-all">{type}</span></label>)}</div></details></fieldset>
|
||
<div className="flex gap-4 sm:col-span-2"><button className="bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Filter events</button><Link className="self-center font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Clear</Link></div>
|
||
</form>
|
||
<div className="divide-y divide-line">
|
||
{recentEvents.map((event) => {
|
||
const ip = eventIpSummary(event.data);
|
||
return <article className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><h3 className="font-mono text-xs font-bold"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${event.id}`}>{event.type}</Link></h3>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><time className="font-mono text-[9px] text-muted" dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></article>;
|
||
})}
|
||
{!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">
|
||
<div className="flex items-center justify-between gap-3"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Access groups</p><Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/groups">Manage</Link></div>
|
||
{effectiveGroup ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3"><Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/groups/${effectiveGroup.id}`}>{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</Link><div className="flex gap-2"><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "Access on" : "Access off"}</span><span className="border border-line px-2 py-1 font-mono text-[9px] font-bold uppercase">VPN {effectiveGroup.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div></div> : <p className="mt-4 text-sm text-accent">No effective group configured.</p>}
|
||
</section>
|
||
|
||
<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>
|
||
<p className="mt-3 text-[10px] leading-5 text-muted">Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.</p>
|
||
<div className="mt-4 divide-y divide-line">
|
||
{addressGroups.map((group) => (
|
||
<div className="py-3" key={group.network}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
||
</div>
|
||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||
</div>
|
||
))}
|
||
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||
</div>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|