feat(groups): add fail-closed admission management
CI / validate (push) Successful in 5m13s
Release / release (push) Successful in 7m8s

This commit is contained in:
dmg
2026-08-01 18:36:45 -04:00
parent 19a5d04178
commit b88097c15a
32 changed files with 1910 additions and 29 deletions
@@ -0,0 +1,92 @@
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
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";
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.",
};
export default async function GroupPage({
params,
searchParams,
}: {
params: Promise<{ groupId: string }>;
searchParams: Promise<{ saved?: string }>;
}) {
const { groupId } = await params;
const query = await searchParams;
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
if (!group) notFound();
const [allUsers, memberships] = await Promise.all([
db.select({
id: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
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)),
]);
const memberIds = new Set(memberships.map((membership) => membership.userId));
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/groups"> All groups</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">Access group</p>
<div className="mt-4 flex flex-wrap items-center gap-3"><h1 className="font-display text-5xl font-black uppercase sm:text-7xl">{group.name}</h1>{group.isDefault && <span className="bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}</div>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
</div>
<form action={setGroupAccess} className="border-l-2 border-accent pl-5">
<input name="groupId" type="hidden" value={group.id} />
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Minecraft admission</p>
<p className="mt-2 font-display text-2xl font-black uppercase">{group.accessEnabled ? "Allowed" : "Denied"}</p>
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
</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>}
<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>
</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>}
<div className="divide-y divide-line">
{allUsers.map((user) => {
const isMember = group.isDefault || memberIds.has(user.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>
</div>
{group.isDefault ? (
<span className="font-mono text-[9px] font-bold uppercase text-muted">Automatic member</span>
) : (
<form action={isMember ? removeGroupMember : 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>
</form>
)}
</article>
);
})}
{!allUsers.length && <p className="py-8 text-sm text-muted">No registered users yet.</p>}
</div>
</section>
</main>
);
}
@@ -0,0 +1,104 @@
"use server";
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { and, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { recordAdminSubjectEvent } from "@/lib/audit";
import { requireAdminSession } from "@/lib/auth/require-admin";
import { db } from "@/lib/database";
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;
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function groupPath(groupId: string, query?: string) {
return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`;
}
export async function createGroup(formData: FormData) {
const admin = await requireAdminSession();
const name = String(formData.get("name") ?? "").trim();
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
const description = String(formData.get("description") ?? "").trim();
if (name.length < 1 || name.length > 50 || !SLUG_PATTERN.test(slug) || slug.length > 50 || description.length > 500) {
redirect("/admin/groups?error=invalid-group");
}
let group: { id: string } | undefined;
try {
[group] = await db.insert(groups).values({
name,
slug,
description: description || null,
accessEnabled: false,
isDefault: false,
}).returning({ id: groups.id });
} catch {
redirect("/admin/groups?error=duplicate-group");
}
if (!group) redirect("/admin/groups?error=create-failed");
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.created", {
name,
slug,
accessEnabled: false,
});
redirect(groupPath(group.id, "saved=created"));
}
export async function setGroupAccess(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const accessEnabled = formData.get("accessEnabled") === "yes";
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
const [group] = await db.update(groups).set({ accessEnabled, updatedAt: new Date() })
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
if (!group) redirect("/admin/groups?error=unknown-group");
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.access-updated", {
name: group.name,
accessEnabled,
});
redirect(groupPath(group.id, "saved=access"));
}
export async function addGroupMember(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 [[group], [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 (!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", {
groupId: group.id,
groupName: group.name,
});
redirect(groupPath(group.id, "saved=member-added"));
}
export async function removeGroupMember(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 [group] = await db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
.from(groups).where(eq(groups.id, groupId)).limit(1);
if (!group || group.isDefault) redirect("/admin/groups?error=invalid-membership");
await db.delete(userGroupMemberships).where(and(
eq(userGroupMemberships.groupId, group.id),
eq(userGroupMemberships.userId, userId),
));
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.member-removed", {
groupId: group.id,
groupName: group.name,
});
redirect(groupPath(group.id, "saved=member-removed"));
}
@@ -0,0 +1,78 @@
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { asc, desc } from "drizzle-orm";
import Link from "next/link";
import { db } from "@/lib/database";
import { createGroup, setGroupAccess } from "./actions";
const errors: Record<string, string> = {
"invalid-group": "Enter a name and a lowercase slug containing letters, numbers, or hyphens.",
"duplicate-group": "That group slug already exists.",
"create-failed": "The group could not be created.",
"unknown-group": "That group no longer exists.",
"invalid-membership": "That membership change was invalid.",
};
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const query = await searchParams;
const [allGroups, memberships, registeredUsers] = await Promise.all([
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
db.select({ id: users.id }).from(users),
]);
const membershipCounts = new Map<string, number>();
for (const membership of memberships) {
membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1);
}
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>
</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>}
<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;
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">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-display text-2xl font-black uppercase">{group.name}</h2>
{group.isDefault && <span className="bg-ink px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}
</div>
<p className="mt-1 font-mono text-[10px] text-muted">{group.slug} · {memberCount} members</p>
</div>
<span className={`px-3 py-2 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "Access on" : "Access off"}</span>
</div>
<p className="mt-4 min-h-12 text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
<div className="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4">
<Link className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" href={`/admin/groups/${group.id}`}>Manage members</Link>
<form action={setGroupAccess}>
<input name="groupId" type="hidden" value={group.id} />
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
</form>
</div>
</article>
);
})}
</section>
<form action={createGroup} className="mt-12 border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)]">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Create a group</p>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
<label className="text-sm font-bold">Name<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={50} name="name" required /></label>
<label className="text-sm font-bold">Slug<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-mono font-normal outline-none focus:border-accent" maxLength={50} name="slug" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" placeholder="ops" required /></label>
</div>
<label className="mt-5 block text-sm font-bold">Description<textarea className="mt-2 min-h-24 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
<p className="mt-4 text-xs text-muted">New groups start with access disabled.</p>
<button className="mt-6 border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Create group</button>
</form>
</main>
);
}
+2 -1
View File
@@ -30,10 +30,11 @@ export default async function AdminConsoleLayout({ children }: { children: React
<div className="min-h-screen bg-canvas text-ink">
<header className="border-b border-line bg-panel">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">Blocklist / Ops</Link>
<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>
<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>
</nav>
<AdminSignOutButton />
+5 -3
View File
@@ -11,6 +11,8 @@ export default async function AdminPage({
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">
@@ -19,9 +21,9 @@ export default async function AdminPage({
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
<dl className="mt-7 space-y-2 font-mono text-[10px] uppercase tracking-wider text-muted">
<div><dt className="inline font-bold text-ink">Guild:</dt> <dd className="inline">{process.env.DISCORD_GUILD_ID ? "configured" : "missing"}</dd></div>
<div><dt className="inline font-bold text-ink">Invite:</dt> <dd className="inline">{process.env.DISCORD_INVITE_URL ? "configured" : "missing"}</dd></div>
<dl 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>
</section>
@@ -1,10 +1,11 @@
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, 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 { groupAccessAddresses } from "@/lib/access-address-groups";
import { db } from "@/lib/database";
import { discordIdentity } from "@/lib/discord-identity";
import { eventIpSummary } from "@/lib/event-ip-summary";
import {
addUserMinecraftAccount,
@@ -44,7 +45,7 @@ export default async function AdminUserPage({
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) notFound();
const [accounts, recentEvents, observations] = await Promise.all([
const [accounts, recentEvents, observations, discord, accessGroups] = await Promise.all([
db
.select()
.from(minecraftAccounts)
@@ -62,6 +63,12 @@ export default async function AdminUserPage({
.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, 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 addressGroups = groupAccessAddresses(
observations.map((observation) => ({ ...observation, intelligence: null })),
@@ -78,7 +85,12 @@ export default async function AdminUserPage({
<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>
<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>
@@ -164,6 +176,13 @@ export default async function AdminUserPage({
<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>
<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>
</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>
@@ -15,6 +15,7 @@ export default async function AdminUsersPage({
? or(
ilike(users.firstName, pattern),
ilike(users.discordUsername, pattern),
ilike(users.discordGlobalName, pattern),
eq(users.discordUserId, search),
sql`exists (
select 1 from ${minecraftAccounts}
@@ -33,6 +34,7 @@ export default async function AdminUsersPage({
id: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
discordGlobalName: users.discordGlobalName,
discordUserId: users.discordUserId,
onboardingCompletedAt: users.onboardingCompletedAt,
primaryUsername: minecraftAccounts.username,
@@ -85,7 +87,7 @@ export default async function AdminUsersPage({
{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"><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>
<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>