feat(portal): add SSR operations and exclusive groups
CI / validate (push) Successful in 5m20s
Release / release (push) Successful in 6m56s

This commit is contained in:
dmg
2026-08-01 19:21:23 -04:00
parent b88097c15a
commit b7c0083647
45 changed files with 2245 additions and 363 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ export async function saveDiscordSettings(formData: FormData) {
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
redirect("/admin?error=invalid-message");
redirect("/admin/settings?error=invalid-message");
}
await db
@@ -28,5 +28,5 @@ export async function saveDiscordSettings(formData: FormData) {
},
});
redirect("/admin?saved=1");
redirect("/admin/settings?saved=1");
}
@@ -0,0 +1,56 @@
import { events } from "@minecraft-account-manager/database";
import { eq } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/database";
import { eventIpSummary } from "@/lib/event-ip-summary";
export const dynamic = "force-dynamic";
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;
export default async function EventDetailPage({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = await params;
if (!UUID_PATTERN.test(eventId)) notFound();
const [event] = await db.select().from(events).where(eq(events.id, eventId)).limit(1);
if (!event) notFound();
const network = eventIpSummary(event.data);
return (
<main className="mx-auto max-w-5xl px-6 py-12">
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/events"> Event explorer</Link>
<header className="mt-7 border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvent detail</p>
<h1 className="mt-4 break-words font-display text-3xl font-black uppercase sm:text-5xl">{event.type}</h1>
<p className="mt-4 break-all font-mono text-xs text-muted">{event.id}</p>
</header>
<section className="mt-8 border border-line bg-panel p-6 shadow-[7px_7px_0_var(--color-shadow)]">
<h2 className="font-display text-2xl font-black uppercase">Envelope</h2>
<dl className="mt-5 grid gap-x-8 gap-y-5 sm:grid-cols-2">
<Detail label="Time"><time dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></Detail>
<Detail label="Spec version">{event.specVersion}</Detail>
<Detail label="Source">{event.source}</Detail>
<Detail label="Subject">{event.subject ?? "Not provided"}</Detail>
<Detail label="Content type">{event.dataContentType}</Detail>
<Detail label="Data schema">{event.dataSchema ?? "Not provided"}</Detail>
<Detail label="Actor user">{event.actorUserId ? <Link className="underline underline-offset-4" href={`/admin/users/${event.actorUserId}`}>{event.actorUserId}</Link> : "Not provided"}</Detail>
<Detail label="Correlation ID">{event.correlationId ?? "Not provided"}</Detail>
<Detail label="IP address">{event.ipAddress ?? "Not provided"}</Detail>
<Detail label="Network">{network.classification || network.location ? `${network.classification ?? "unknown"} · ${network.location ?? "location unavailable"}` : "Not provided"}</Detail>
<Detail label="Published">{event.publishedAt ? event.publishedAt.toISOString() : "Pending publication"}</Detail>
<Detail label="Recorded">{event.createdAt.toISOString()}</Detail>
</dl>
</section>
<section className="mt-10">
<h2 className="font-display text-2xl font-black uppercase">Event data</h2>
<pre className="mt-4 overflow-x-auto border border-line bg-ink p-5 font-mono text-xs leading-6 text-canvas" tabIndex={0}>{JSON.stringify(event.data, null, 2)}</pre>
</section>
</main>
);
}
function Detail({ label, children }: { label: string; children: React.ReactNode }) {
return <div><dt className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">{label}</dt><dd className="mt-1 break-all text-sm">{children}</dd></div>;
}
@@ -1,34 +1,92 @@
import { events } from "@minecraft-account-manager/database";
import { desc } from "drizzle-orm";
import { desc, inArray } from "drizzle-orm";
import Link from "next/link";
import { db } from "@/lib/database";
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
import { eventIpSummary } from "@/lib/event-ip-summary";
export default async function EventsPage() {
const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100);
export const dynamic = "force-dynamic";
function values(value: string | string[] | undefined) {
return Array.isArray(value) ? value : value ? [value] : [];
}
export default async function EventsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const query = await searchParams;
const typeRows = await db.select({ type: events.type }).from(events).groupBy(events.type).orderBy(events.type);
const availableTypes = typeRows.map((row) => row.type);
const category = normalizeEventCategory(values(query.category)[0]);
const selectedTypes = normalizeSelectedEventTypes(query.type, availableTypes);
const categoryTypes = category === "all"
? availableTypes
: availableTypes.filter((type) => eventCategory(type) === category);
const filteredTypes = selectedTypes.length
? selectedTypes.filter((type) => categoryTypes.includes(type))
: categoryTypes;
const recentEvents = filteredTypes.length
? await db.select().from(events).where(inArray(events.type, filteredTypes)).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)]">
<h1 className="mt-4 font-display text-5xl font-black uppercase">Event explorer</h1>
<p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Filter the immutable audit ledger, then open an event to inspect its complete CloudEvents envelope and data.</p>
<form className="mt-8 border border-line bg-panel p-6" method="get">
<div className="grid gap-6 md:grid-cols-[0.45fr_1.55fr]">
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="event-category">
View
<select className="mt-3 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case" defaultValue={category} id="event-category" name="category">
{eventCategoryValues.map((value) => <option key={value} value={value}>{value === "all" ? "All activity" : value}</option>)}
</select>
</label>
<fieldset>
<legend className="font-mono text-xs font-bold uppercase tracking-wider">Event types</legend>
<details className="mt-3 border border-line bg-canvas p-4" open={selectedTypes.length > 0}>
<summary className="cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">{selectedTypes.length ? `${selectedTypes.length} selected` : "All types in this view"}</summary>
<div className="mt-4 grid max-h-64 gap-3 overflow-y-auto sm:grid-cols-2">
{availableTypes.map((type) => (
<label className="flex items-start gap-2 font-mono text-[10px] leading-4" key={type}>
<input className="mt-0.5 size-4 accent-[var(--accent)]" defaultChecked={selectedTypes.includes(type)} name="type" type="checkbox" value={type} />
<span className="break-all">{type}</span>
</label>
))}
</div>
</details>
</fieldset>
</div>
<div className="mt-5 flex flex-wrap gap-4">
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Apply filters</button>
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/events">Clear filters</Link>
</div>
</form>
<p className="mt-8 font-mono text-[10px] uppercase tracking-widest text-muted" role="status">Showing {recentEvents.length} most recent matching events</p>
<div className="mt-3 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">
<caption className="sr-only">Filtered account manager events</caption>
<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><th className="p-4">Network</th></tr>
<tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Type</th><th className="p-4" scope="col">Subject</th><th className="p-4" scope="col">IP</th><th className="p-4" scope="col">Network</th></tr>
</thead>
<tbody className="divide-y divide-line text-xs">
{recentEvents.map((event) => {
const ip = eventIpSummary(event.data);
return (
<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>
<tr className="hover:bg-canvas/60" key={event.id}>
<td className="whitespace-nowrap p-4 font-mono text-muted"><time dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></td>
<th className="p-4 text-left font-mono font-bold" scope="row"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${event.id}`}>{event.type}</Link></th>
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
<td className="p-4"><div className="font-mono text-[10px] font-bold uppercase">{ip.classification ?? "—"}</div><div className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"}</div></td>
</tr>
);
})}
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events have been recorded.</td></tr>}
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events match these filters.</td></tr>}
</tbody>
</table>
</div>
@@ -3,15 +3,17 @@ import { asc, eq } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/database";
import { addGroupMember, removeGroupMember, setGroupAccess } from "../actions";
import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess } from "../actions";
const savedMessages: Record<string, string> = {
created: "Group created with access disabled.",
access: "Group access policy updated.",
"member-added": "User added to the group.",
"member-removed": "User removed from the group.",
"member-added": "User assigned to the group.",
"member-removed": "User returned to the default group.",
};
export const dynamic = "force-dynamic";
export default async function GroupPage({
params,
searchParams,
@@ -32,10 +34,16 @@ export default async function GroupPage({
discordGlobalName: users.discordGlobalName,
discordUserId: users.discordUserId,
}).from(users).orderBy(asc(users.discordUsername)),
db.select({ userId: userGroupMemberships.userId }).from(userGroupMemberships)
.where(eq(userGroupMemberships.groupId, group.id)),
db.select({
userId: userGroupMemberships.userId,
groupId: userGroupMemberships.groupId,
groupName: groups.name,
}).from(userGroupMemberships).innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)),
]);
const memberIds = new Set(memberships.map((membership) => membership.userId));
const assignmentByUser = new Map(memberships.map((membership) => [membership.userId, membership]));
const memberCount = group.isDefault
? allUsers.length - assignmentByUser.size
: memberships.filter((membership) => membership.groupId === group.id).length;
return (
<main className="mx-auto max-w-6xl px-6 py-12">
@@ -55,30 +63,38 @@ export default async function GroupPage({
</form>
</header>
{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] ?? "Group updated."}</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" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
<section className="mt-10">
<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">Membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Registered users</h2></div>
<span className="font-mono text-xs text-muted">{group.isDefault ? allUsers.length : memberIds.size} members</span>
<span className="font-mono text-xs text-muted">{memberCount} members</span>
</div>
{group.isDefault && <p className="border-b border-line bg-panel px-5 py-4 text-sm text-muted">Membership in <strong className="text-ink">everyone</strong> is automatic and cannot be removed.</p>}
{group.isDefault && <p className="border-b border-line bg-panel px-5 py-4 text-sm text-muted">Users belong to <strong className="text-ink">everyone</strong> only while they have no explicit group assignment.</p>}
<div className="divide-y divide-line">
{allUsers.map((user) => {
const isMember = group.isDefault || memberIds.has(user.id);
const assignment = assignmentByUser.get(user.id);
const isMember = group.isDefault ? !assignment : assignment?.groupId === group.id;
return (
<article className="grid gap-4 py-5 sm:grid-cols-[1fr_auto] sm:items-center" key={user.id}>
<div>
<Link className="font-mono text-sm font-bold underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? user.discordGlobalName ?? user.discordUsername}</Link>
<p className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername} · {user.discordUserId}</p>
{!isMember && assignment && <p className="mt-1 text-xs text-muted">Currently assigned to {assignment.groupName}</p>}
</div>
{group.isDefault ? (
<span className="font-mono text-[9px] font-bold uppercase text-muted">Automatic member</span>
{isMember ? (
group.isDefault ? <span className="font-mono text-[9px] font-bold uppercase text-muted">Default assignment</span> : (
<form action={removeGroupMember}>
<input name="groupId" type="hidden" value={group.id} />
<input name="userId" type="hidden" value={user.id} />
<button className="font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Return to everyone</button>
</form>
)
) : (
<form action={isMember ? removeGroupMember : addGroupMember}>
<form action={group.isDefault ? assignDefaultGroup : addGroupMember}>
<input name="groupId" type="hidden" value={group.id} />
<input name="userId" type="hidden" value={user.id} />
<button className={`font-mono text-[9px] font-bold uppercase underline underline-offset-4 ${isMember ? "text-accent" : "text-ink"}`} type="submit">{isMember ? "Remove from group" : "Add to group"}</button>
<button className="font-mono text-[9px] font-bold uppercase text-ink underline underline-offset-4" type="submit">Move to {group.name}</button>
</form>
)}
</article>
@@ -87,6 +103,23 @@ export default async function GroupPage({
{!allUsers.length && <p className="py-8 text-sm text-muted">No registered users yet.</p>}
</div>
</section>
{!group.isDefault && (
<section className="mt-12 border border-accent bg-panel p-6">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p>
<h2 className="mt-3 font-display text-2xl font-black uppercase">Delete {group.name}</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">Deleting this group returns its {memberCount} {memberCount === 1 ? "member" : "members"} to the protected default group. This cannot be undone.</p>
<details className="mt-5">
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4">Review deletion</summary>
<form action={deleteGroup} className="mt-4 flex flex-wrap items-center gap-4">
<input name="groupId" type="hidden" value={group.id} />
<input name="confirmDelete" type="hidden" value="yes" />
<button className="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Delete group permanently</button>
<span className="text-xs text-muted">Members will use everyone immediately.</span>
</form>
</details>
</section>
)}
</main>
);
}
@@ -1,7 +1,10 @@
"use server";
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { randomUUID } from "node:crypto";
import { events, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { getClientIp } from "@minecraft-account-manager/network";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { recordAdminSubjectEvent } from "@/lib/audit";
import { requireAdminSession } from "@/lib/auth/require-admin";
@@ -74,10 +77,22 @@ export async function addGroupMember(formData: FormData) {
]);
if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership");
await db.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id }).onConflictDoNothing();
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.member-added", {
const previousGroup = await db.transaction(async (tx) => {
const [previous] = await tx
.select({ id: groups.id, name: groups.name })
.from(userGroupMemberships)
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
.where(eq(userGroupMemberships.userId, user.id))
.limit(1);
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
await tx.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id });
return previous ?? null;
});
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
groupId: group.id,
groupName: group.name,
previousGroupId: previousGroup?.id ?? null,
previousGroupName: previousGroup?.name ?? "everyone",
});
redirect(groupPath(group.id, "saved=member-added"));
}
@@ -96,9 +111,81 @@ export async function removeGroupMember(formData: FormData) {
eq(userGroupMemberships.groupId, group.id),
eq(userGroupMemberships.userId, userId),
));
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.member-removed", {
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.assignment-removed", {
groupId: group.id,
groupName: group.name,
fallbackGroup: "everyone",
});
redirect(groupPath(group.id, "saved=member-removed"));
}
export async function assignDefaultGroup(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const userId = String(formData.get("userId") ?? "");
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
const [[defaultGroup], [user]] = await Promise.all([
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
]);
if (!defaultGroup?.isDefault || !user) redirect("/admin/groups?error=invalid-membership");
const [previous] = await db
.select({ id: groups.id, name: groups.name })
.from(userGroupMemberships)
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
.where(eq(userGroupMemberships.userId, user.id))
.limit(1);
await db.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
groupId: defaultGroup.id,
groupName: defaultGroup.name,
previousGroupId: previous?.id ?? null,
previousGroupName: previous?.name ?? null,
});
redirect(groupPath(defaultGroup.id, "saved=member-added"));
}
export async function deleteGroup(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const confirmed = formData.get("confirmDelete") === "yes";
if (!UUID_PATTERN.test(groupId) || !confirmed) redirect("/admin/groups?error=invalid-delete");
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
const deleted = await db.transaction(async (tx) => {
const [group] = await tx
.select({ id: groups.id, name: groups.name, slug: groups.slug, isDefault: groups.isDefault })
.from(groups)
.where(eq(groups.id, groupId))
.limit(1);
if (!group || group.isDefault) return null;
const members = await tx
.select({ userId: userGroupMemberships.userId })
.from(userGroupMemberships)
.where(eq(userGroupMemberships.groupId, group.id));
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.group.deleted",
subject: `group/${group.id}`,
time: new Date(),
data: {
name: group.name,
slug: group.slug,
affectedUsers: members.length,
fallbackGroup: "everyone",
adminEmail: admin.email,
adminName: admin.name,
},
ipAddress: ipAddress ?? null,
});
await tx.delete(groups).where(eq(groups.id, group.id));
return group;
});
if (!deleted) redirect("/admin/groups?error=protected-group");
redirect("/admin/groups?saved=deleted");
}
@@ -10,9 +10,13 @@ const errors: Record<string, string> = {
"create-failed": "The group could not be created.",
"unknown-group": "That group no longer exists.",
"invalid-membership": "That membership change was invalid.",
"invalid-delete": "Confirm the group deletion before continuing.",
"protected-group": "The protected default group cannot be deleted.",
};
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
export const dynamic = "force-dynamic";
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
const query = await searchParams;
const [allGroups, memberships, registeredUsers] = await Promise.all([
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
@@ -23,20 +27,22 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
for (const membership of memberships) {
membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1);
}
const explicitlyAssignedUsers = memberships.length;
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<header className="border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Admission policy</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
<p className="mt-5 max-w-2xl leading-7 text-muted">Every registered user belongs to <strong className="text-ink">everyone</strong>. A player can join only when at least one of their groups has access enabled.</p>
<p className="mt-5 max-w-2xl leading-7 text-muted">Each user has one effective group. Users without an explicit assignment fall back to <strong className="text-ink">everyone</strong>; Minecraft admission follows only that groups access setting.</p>
</header>
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">{errors[query.error] ?? "The group operation failed."}</p>}
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errors[query.error] ?? "The group operation failed."}</p>}
{query.saved === "deleted" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">Group deleted. Its former members now use the default group.</p>}
<section className="mt-10 grid gap-5 md:grid-cols-2">
{allGroups.map((group) => {
const memberCount = group.isDefault ? registeredUsers.length : membershipCounts.get(group.id) ?? 0;
const memberCount = group.isDefault ? registeredUsers.length - explicitlyAssignedUsers : membershipCounts.get(group.id) ?? 0;
return (
<article className="border border-line bg-panel p-6 shadow-[5px_5px_0_var(--color-shadow)]" key={group.id}>
<div className="flex items-start justify-between gap-4">
+6 -3
View File
@@ -9,6 +9,8 @@ import { AdminSignOutButton } from "@/components/admin-sign-out-button";
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
import { db } from "@/lib/database";
export const dynamic = "force-dynamic";
export default async function AdminConsoleLayout({ children }: { children: ReactNode }) {
const session = await getServerSession(adminAuthOptions);
if (!session) redirect("/admin/login");
@@ -29,10 +31,11 @@ export default async function AdminConsoleLayout({ children }: { children: React
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">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-5 px-6 py-5">
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">SoMC Portal / 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>
<nav aria-label="Administrator" className="ml-auto flex flex-wrap gap-5 font-mono text-[10px] font-bold uppercase tracking-widest">
<Link className="hover:text-accent" href="/admin">Dashboard</Link>
<Link className="hover:text-accent" href="/admin/settings">Settings</Link>
<Link className="hover:text-accent" href="/admin/users">Users</Link>
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
<Link className="hover:text-accent" href="/admin/events">Events</Link>
+132 -40
View File
@@ -1,50 +1,142 @@
import { eq } from "drizzle-orm";
import { appSettings } from "@minecraft-account-manager/database";
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
import Link from "next/link";
import { db } from "@/lib/database";
import { saveDiscordSettings } from "./actions";
import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
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.";
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
export const dynamic = "force-dynamic";
export default async function AdminDashboardPage() {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1_000);
const fourteenDaysAgo = new Date(now.getTime() - 13 * 24 * 60 * 60 * 1_000);
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
const [registrationRows, [totals], [monthlyActive], riskyActivity, [recentDenials]] = await Promise.all([
db
.select({
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
count: count(),
})
.from(users)
.where(gte(users.createdAt, fourteenDaysAgo))
.groupBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`)
.orderBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`),
db.select({ users: count(users.id) }).from(users),
db.select({
users: countDistinct(ipObservations.userId),
accounts: countDistinct(ipObservations.minecraftAccountId),
}).from(ipObservations).where(and(
gte(ipObservations.observedAt, thirtyDaysAgo),
isNotNull(ipObservations.userId),
)),
db
.select({
id: ipObservations.id,
classification: ipObservations.classification,
observedAt: ipObservations.observedAt,
source: ipObservations.source,
userId: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
accountUsername: minecraftAccounts.username,
})
.from(ipObservations)
.leftJoin(users, eq(users.id, ipObservations.userId))
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
.where(inArray(ipObservations.classification, ["vpn", "proxy", "tor"]))
.orderBy(desc(ipObservations.observedAt))
.limit(10),
db.select({ count: count() }).from(events).where(and(
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
gte(events.time, oneDayAgo),
)),
]);
const registrations = fillDailySeries(registrationRows as DailyCount[], now, 14);
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-4 font-mono text-[10px] uppercase tracking-wider text-muted">
<div><dt className="font-bold text-ink">Guild ID</dt><dd className="mt-1 break-all normal-case">{guildId ?? "Missing"}</dd></div>
<div><dt className="font-bold text-ink">Invite URL</dt><dd className="mt-1 break-all normal-case">{inviteUrl ? <a className="text-ink underline decoration-accent underline-offset-4" href={inviteUrl} rel="noreferrer" target="_blank">{inviteUrl}</a> : "Missing"}</dd></div>
</dl>
<header className="border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Operations overview</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Dashboard</h1>
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
</header>
<section aria-label="Key metrics" className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="Registered users" value={totals?.users ?? 0} detail="All time" />
<Metric label="Monthly active users" value={monthlyActive?.users ?? 0} detail="Distinct users · 30 days" />
<Metric label="Active Minecraft accounts" value={monthlyActive?.accounts ?? 0} detail="Distinct accounts · 30 days" />
<Metric label="Login denials" value={recentDenials?.count ?? 0} detail="Past 24 hours" accent />
</section>
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
<RegistrationChart data={registrations} />
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<div className="flex items-start justify-between gap-4">
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Network review</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Recent VPN activity</h2></div>
<Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/events?category=security">All security events</Link>
</div>
<div className="mt-5 divide-y divide-line">
{riskyActivity.map((activity) => (
<article className="py-4" key={activity.id}>
<div className="flex items-start justify-between gap-3">
<div>
{activity.userId ? <Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/users/${activity.userId}`}>{activity.firstName ?? activity.discordUsername ?? "Unknown user"}</Link> : <span className="font-mono text-xs font-bold">Unknown user</span>}
<p className="mt-1 text-xs text-muted">{activity.accountUsername ?? "No Minecraft account"} · {activity.source}</p>
</div>
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classification}</span>
</div>
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
</article>
))}
{!riskyActivity.length && <p className="py-6 text-sm text-muted">No recent VPN, proxy, or Tor observations.</p>}
</div>
</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>
);
}
function Metric({ label, value, detail, accent = false }: { label: string; value: number; detail: string; accent?: boolean }) {
return (
<article className={`border p-5 ${accent ? "border-accent bg-ink text-canvas" : "border-line bg-panel"}`}>
<p className={`font-mono text-[9px] font-bold uppercase tracking-widest ${accent ? "text-signal" : "text-muted"}`}>{label}</p>
<p className="mt-3 font-display text-5xl font-black">{value}</p>
<p className={`mt-2 text-xs ${accent ? "text-canvas" : "text-muted"}`}>{detail}</p>
</article>
);
}
function RegistrationChart({ data }: { data: DailyCount[] }) {
const width = 720;
const height = 260;
const padding = 32;
const maximum = Math.max(1, ...data.map((entry) => entry.count));
const points = data.map((entry, index) => {
const x = padding + index * ((width - padding * 2) / Math.max(1, data.length - 1));
const y = height - padding - (entry.count / maximum) * (height - padding * 2);
return `${x},${y}`;
}).join(" ");
return (
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Growth signal</p>
<h2 className="mt-2 font-display text-2xl font-black uppercase">New users by day</h2>
<svg aria-labelledby="registration-chart-title registration-chart-description" className="mt-6 h-auto w-full" role="img" viewBox={`0 0 ${width} ${height}`}>
<title id="registration-chart-title">New user registrations over the last 14 days</title>
<desc id="registration-chart-description">Daily registrations range from zero to {maximum}. A text summary follows the chart.</desc>
<line stroke="var(--line)" strokeWidth="1" x1={padding} x2={width - padding} y1={height - padding} y2={height - padding} />
<polyline fill="none" points={points} stroke="var(--accent)" strokeLinecap="square" strokeLinejoin="miter" strokeWidth="4" />
{data.map((entry, index) => {
const [x, y] = points.split(" ")[index]!.split(",");
return <circle cx={x} cy={y} fill="var(--panel)" key={entry.day} r="5" stroke="var(--ink)" strokeWidth="3"><title>{entry.day}: {entry.count} new users</title></circle>;
})}
</svg>
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center">
{data.map((entry) => <div key={entry.day}><dt className="sr-only">{entry.day}</dt><dd className="font-mono text-xs font-bold">{entry.count}</dd></div>)}
</dl>
<div aria-hidden="true" className="mt-2 flex justify-between font-mono text-[9px] text-muted"><span>{data[0]?.day}</span><span>{data.at(-1)?.day}</span></div>
</section>
);
}
@@ -0,0 +1,51 @@
import { appSettings } from "@minecraft-account-manager/database";
import { eq } from "drizzle-orm";
import { db } from "@/lib/database";
import { saveDiscordSettings } from "../actions";
export const dynamic = "force-dynamic";
export default async function SettingsPage({
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.";
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
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-4 font-mono text-[10px] uppercase tracking-wider text-muted">
<div><dt className="font-bold text-ink">Guild ID</dt><dd className="mt-1 break-all normal-case">{guildId ?? "Missing"}</dd></div>
<div><dt className="font-bold text-ink">Invite URL</dt><dd className="mt-1 break-all normal-case">{inviteUrl ? <a className="text-ink underline decoration-accent underline-offset-4" href={inviteUrl} rel="noreferrer" target="_blank">{inviteUrl}<span className="sr-only"> (opens in a new tab)</span></a> : "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" role="status">Settings saved</p>}
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm" role="alert">Check the configuration value 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>
);
}
@@ -1,11 +1,13 @@
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, isNull, or } from "drizzle-orm";
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,
@@ -33,30 +35,50 @@ const savedMessages: Record<string, string> = {
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<{ error?: string; saved?: string; unverified?: 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 [accounts, recentEvents, observations, discord, accessGroups] = await Promise.all([
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),
db
.select()
.from(events)
.where(or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id)))
.orderBy(desc(events.time))
.limit(30),
recentEventsQuery,
db
.select()
.from(ipObservations)
@@ -70,6 +92,9 @@ export default async function AdminUserPage({
.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 })),
);
@@ -99,8 +124,8 @@ export default async function AdminUserPage({
</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>}
{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">
@@ -136,10 +161,10 @@ export default async function AdminUserPage({
{!accounts.length && <p className="py-7 text-sm text-muted">No active Minecraft accounts.</p>}
</div>
{query.unverified ? (
{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>
<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 couldnt 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>
@@ -147,7 +172,7 @@ export default async function AdminUserPage({
) : (
<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 />
<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>
)}
@@ -155,11 +180,16 @@ export default async function AdminUserPage({
<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>
<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 <div className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><p className="font-mono text-xs font-bold">{event.type}</p>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><span className="font-mono text-[9px] text-muted">{event.time.toISOString()}</span></div>;
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>
@@ -178,9 +208,7 @@ export default async function AdminUserPage({
<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>
<div className="mt-4 space-y-3">
{accessGroups.map((group) => <div className="flex items-center justify-between gap-3" key={group.id}><Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/groups/${group.id}`}>{group.name}{group.isDefault ? " · default" : ""}</Link><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "On" : "Off"}</span></div>)}
</div>
{effectiveGroup ? <div className="mt-4 flex 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><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 ? "On" : "Off"}</span></div> : <p className="mt-4 text-sm text-accent">No effective group configured.</p>}
</section>
<section className="border border-line bg-panel p-6">
@@ -3,6 +3,8 @@ import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
import Link from "next/link";
import { db } from "@/lib/database";
export const dynamic = "force-dynamic";
export default async function AdminUsersPage({
searchParams,
}: {
@@ -66,6 +68,7 @@ export default async function AdminUsersPage({
</div>
<form className="flex w-full max-w-md gap-2" method="get">
<input
aria-label="Search users"
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"
@@ -76,17 +79,18 @@ export default async function AdminUsersPage({
</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>}
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">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">
<caption className="sr-only">Registered portal users</caption>
<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>
<tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">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>
<th className="p-4 text-left" scope="row"><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></th>
<td className="p-4"><div className="font-mono text-xs font-bold">{user.discordGlobalName ?? user.discordUsername}</div><div className="mt-1 font-mono text-[10px] text-muted">@{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>