feat(groups): add fail-closed admission management
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { ipIntelligence, ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, isNull, or } from "drizzle-orm";
|
||||
import { logout } from "@/app/auth/actions";
|
||||
import { db } from "@/lib/database";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { groupAccessAddresses } from "@/lib/access-address-groups";
|
||||
import { discordIdentity } from "@/lib/discord-identity";
|
||||
import { intelligenceSummary } from "@/lib/event-ip-summary";
|
||||
import {
|
||||
addMinecraftAccount,
|
||||
@@ -38,7 +39,7 @@ export default async function AccountPage({
|
||||
const query = await searchParams;
|
||||
const error = queryValue(query.error);
|
||||
const unverified = queryValue(query.unverified);
|
||||
const [accounts, observations] = await Promise.all([
|
||||
const [accounts, observations, discord, accessGroups] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(minecraftAccounts)
|
||||
@@ -58,6 +59,12 @@ export default async function AccountPage({
|
||||
.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);
|
||||
const primary = accounts.find((account) => account.isPrimary);
|
||||
@@ -209,12 +216,25 @@ export default async function AccountPage({
|
||||
<aside>
|
||||
<form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
|
||||
<dl className="mt-5 space-y-3 border-b border-line pb-5 font-mono text-[10px]">
|
||||
<div><dt className="uppercase tracking-wider text-muted">Discord name</dt><dd className="mt-1 break-all font-bold text-ink">{discord.globalName ?? discord.username}</dd></div>
|
||||
<div><dt className="uppercase tracking-wider text-muted">Discord username</dt><dd className="mt-1 break-all text-ink">@{discord.username}</dd></div>
|
||||
<div><dt className="uppercase tracking-wider text-muted">Guild nickname</dt><dd className="mt-1 break-all text-ink">{discord.nickname ?? "No guild nickname"}</dd></div>
|
||||
<div><dt className="uppercase tracking-wider text-muted">Discord ID</dt><dd className="mt-1 break-all text-ink">{discord.id}</dd></div>
|
||||
</dl>
|
||||
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</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 />
|
||||
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Current Discord nickname: <strong className="text-ink">{desiredNickname}</strong></p>}
|
||||
<p className="mt-3 text-xs leading-5 text-muted">You will review the new Discord nickname before anything changes.</p>
|
||||
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Review name change</button>
|
||||
</form>
|
||||
<section className="mt-8 border border-line bg-panel p-6">
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Access groups</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{accessGroups.map((group) => <div className="flex items-center justify-between gap-3 border-t border-line pt-3 first:border-0 first:pt-0" key={group.id}><span className="font-mono text-xs font-bold">{group.name}{group.isDefault ? " · default" : ""}</span><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 ? "Access on" : "Access off"}</span></div>)}
|
||||
</div>
|
||||
<p className="mt-4 text-xs leading-5 text-muted">Minecraft access is allowed when any listed group has access on.</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||
import { enabledAccessGroup, isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
|
||||
import {
|
||||
appSettings,
|
||||
events,
|
||||
groups,
|
||||
ipObservations,
|
||||
minecraftAccounts,
|
||||
pluginCredentials,
|
||||
pluginRequests,
|
||||
userGroupMemberships,
|
||||
} from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import { and, eq, isNull, lt, or, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||
@@ -210,6 +212,42 @@ async function handleVelocityAccess(request: Request) {
|
||||
return { allowed: false as const, message: denialMessage };
|
||||
}
|
||||
|
||||
const assignedGroups = await tx
|
||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
|
||||
.from(groups)
|
||||
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||||
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, account.userId)));
|
||||
const enabledGroup = enabledAccessGroup(assignedGroups);
|
||||
|
||||
if (!enabledGroup) {
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
type: "games.minecraft.account-manager.game.login.denied",
|
||||
subject: `minecraft-account/${account.id}`,
|
||||
time: occurredAt,
|
||||
actorUserId: account.userId,
|
||||
data: {
|
||||
username: input.username,
|
||||
reason: "group_access_disabled",
|
||||
ipIntelligence: auditIpData,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
await tx.insert(ipObservations).values({
|
||||
userId: account.userId,
|
||||
minecraftAccountId: account.id,
|
||||
source: "game",
|
||||
ipAddress: input.ipAddress,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
classification: intelligence.classification,
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return { allowed: false as const, message: "Your account group does not currently have server access." };
|
||||
}
|
||||
|
||||
if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) {
|
||||
await tx
|
||||
.update(minecraftAccounts)
|
||||
@@ -259,6 +297,7 @@ async function handleVelocityAccess(request: Request) {
|
||||
previousUsername: account.username === input.username ? null : account.username,
|
||||
uuidBackfilled: account.minecraftUuid === null,
|
||||
ipIntelligence: auditIpData,
|
||||
accessGroup: enabledGroup.name,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="8" fill="#171916"/>
|
||||
<path d="M9 9h21v21H9z" fill="#bc3f24"/>
|
||||
<path d="M34 9h21v21H34zM9 34h21v21H9z" fill="#eee8d8"/>
|
||||
<path d="M34 34h21v21H34z" fill="#b5d452"/>
|
||||
<path d="M21 18h13v8H26v4h8v8H21z" fill="#171916"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
@@ -4,7 +4,7 @@ import { SiteFooter } from "@/components/site-footer";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blocklist — Minecraft Account Manager",
|
||||
title: "SoMC Portal — Minecraft Account Manager",
|
||||
description: "Connect your Discord identity to approved Minecraft accounts.",
|
||||
};
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ export default async function HomePage({
|
||||
<div className="terrain" aria-hidden="true" />
|
||||
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-6 pb-10 pt-7 sm:px-10 lg:px-16">
|
||||
<header className="flex items-center justify-between border-b border-line pb-5">
|
||||
<a className="flex items-center gap-3" href="#top" aria-label="Blocklist home">
|
||||
<a className="flex items-center gap-3" href="#top" aria-label="SoMC Portal home">
|
||||
<span className="grid size-9 place-items-center border border-accent bg-accent text-sm font-black text-canvas shadow-[4px_4px_0_var(--color-shadow)]">
|
||||
B
|
||||
</span>
|
||||
<span className="font-display text-sm font-bold uppercase tracking-[0.22em]">Blocklist</span>
|
||||
<span className="font-display text-sm font-bold uppercase tracking-[0.22em]">SoMC Portal</span>
|
||||
</a>
|
||||
<span className="hidden items-center gap-2 font-mono text-xs uppercase tracking-widest text-muted sm:flex">
|
||||
<span className="size-2 bg-signal shadow-[0_0_12px_var(--color-signal)]" />
|
||||
|
||||
@@ -5,9 +5,9 @@ import { db } from "@/lib/database";
|
||||
|
||||
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
|
||||
|
||||
export async function recordAdminEvent(
|
||||
export async function recordAdminSubjectEvent(
|
||||
admin: { email: string | null; name: string | null },
|
||||
targetUserId: string,
|
||||
subject: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
@@ -16,12 +16,21 @@ export async function recordAdminEvent(
|
||||
return recordEvent(db, {
|
||||
type,
|
||||
source: "/web/admin",
|
||||
subject: `user/${targetUserId}`,
|
||||
subject,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordAdminEvent(
|
||||
admin: { email: string | null; name: string | null },
|
||||
targetUserId: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
return recordAdminSubjectEvent(admin, `user/${targetUserId}`, type, data);
|
||||
}
|
||||
|
||||
export async function recordUserEvent(
|
||||
user: { id: string },
|
||||
type: string,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getGuildMemberIdentity } from "@minecraft-account-manager/minecraft";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export async function discordIdentity(input: {
|
||||
discordUserId: string;
|
||||
discordUsername: string;
|
||||
discordGlobalName: string | null;
|
||||
}) {
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||
const fallback = {
|
||||
id: input.discordUserId,
|
||||
username: input.discordUsername,
|
||||
globalName: input.discordGlobalName,
|
||||
nickname: null as string | null,
|
||||
live: false,
|
||||
};
|
||||
if (!guildId || !botToken) return fallback;
|
||||
|
||||
try {
|
||||
return { ...await getGuildMemberIdentity({
|
||||
guildId,
|
||||
discordUserId: input.discordUserId,
|
||||
botToken,
|
||||
}), live: true };
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ err: error, event: "discord.identity_lookup_failed", discordUserId: input.discordUserId },
|
||||
"Discord guild identity lookup failed",
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
|
||||
* [US-014 — Receive standardized API errors](us-014-problem-details.md) - Application APIs return RFC 9457 Problem Details.
|
||||
* [US-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls.
|
||||
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
|
||||
* [US-017 — Control admission with groups](us-017-group-access.md) - Administrators assign users to groups that explicitly grant Minecraft access.
|
||||
|
||||
# Tracking
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## 2026-08-01
|
||||
|
||||
* **Extend**: Add SoMC Portal branding, live Discord identity details, admin guild configuration visibility, and fail-closed group-based Minecraft admission.
|
||||
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
|
||||
* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics.
|
||||
* **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin.
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Enter the account portal through Discord
|
||||
description: Direct visitors are guided to the configured Discord community and its account commands.
|
||||
tags: [player, portal, discord, onboarding]
|
||||
timestamp: 2026-08-01T22:04:17Z
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
story_id: US-001
|
||||
status: verified
|
||||
---
|
||||
@@ -19,12 +19,14 @@ As a prospective player, I want the portal to direct me to the community Discord
|
||||
- [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened.
|
||||
- [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions.
|
||||
- [x] Every portal page credits Social Minecraft sponsorship by DMG Games and links to `https://dmg.games`.
|
||||
- [x] Portal branding uses the SoMC Portal name and a dedicated favicon.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx)
|
||||
- [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts)
|
||||
- [`apps/web/src/components/site-footer.tsx`](../apps/web/src/components/site-footer.tsx)
|
||||
- [`apps/web/src/app/icon.svg`](../apps/web/src/app/icon.svg)
|
||||
- Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
|
||||
|
||||
# Validation
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Manage linked accounts from the dashboard
|
||||
description: Authenticated users maintain their profile and active Java Edition accounts.
|
||||
tags: [player, dashboard, minecraft, profile]
|
||||
timestamp: 2026-08-01T22:04:17Z
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
story_id: US-005
|
||||
status: verified
|
||||
---
|
||||
@@ -22,6 +22,8 @@ As a registered player, I want to manage my profile and linked Minecraft account
|
||||
- [x] Removing a primary account promotes another active account when one exists.
|
||||
- [x] Name and primary changes preview the expected Discord nickname and require explicit confirmation before either profile mutation occurs.
|
||||
- [x] The dashboard shows recent portal and game IP observations with classification and available location.
|
||||
- [x] The dashboard shows the user's Discord display name, username, guild nickname, and immutable Discord ID.
|
||||
- [x] The dashboard shows effective access groups and whether each group grants Minecraft access.
|
||||
- [x] The user can revoke the current session by signing out.
|
||||
|
||||
# Implementation
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Enforce registration at the Velocity proxy
|
||||
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
|
||||
tags: [minecraft, velocity, whitelist, security]
|
||||
timestamp: 2026-08-01T18:52:20Z
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
story_id: US-009
|
||||
status: verified
|
||||
---
|
||||
@@ -22,7 +22,8 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
|
||||
- [x] Username fallback applies only when the stored account has no UUID.
|
||||
- [x] Successful fallback backfills UUID and canonical username.
|
||||
- [x] Changed usernames are persisted and audited.
|
||||
- [x] Unknown players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance.
|
||||
- [x] Registered players are allowed only when at least one assigned group has access enabled.
|
||||
- [x] Unknown players, group-disabled players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance.
|
||||
- [x] The plugin records the real Velocity connection IP and supports Java Edition online mode only.
|
||||
|
||||
# Implementation
|
||||
@@ -41,3 +42,4 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
|
||||
|
||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
||||
- [Standardize API errors](us-014-problem-details.md)
|
||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Operate settings and audit views
|
||||
description: Authorized administrators control server messaging and investigate recent platform events.
|
||||
tags: [admin, settings, audit, operations]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
story_id: US-012
|
||||
status: verified
|
||||
---
|
||||
@@ -14,7 +14,7 @@ As an administrator, I want operational settings and audit visibility, so that I
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The admin console reports whether deployment-managed Discord guild and invite settings are configured.
|
||||
- [x] The admin console shows the deployment-managed Discord guild ID and linked invite URL.
|
||||
- [x] An authorized administrator can update the denied-player registration message.
|
||||
- [x] Settings actions validate message length server-side.
|
||||
- [x] Administrators can browse the latest 100 events.
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Manage users as an administrator
|
||||
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
||||
tags: [admin, users, minecraft, discord]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
story_id: US-013
|
||||
status: verified
|
||||
---
|
||||
@@ -16,7 +16,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
||||
|
||||
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
||||
- [x] Search results show onboarding state, primary username, and active account count.
|
||||
- [x] A user detail view shows Discord identity, active accounts, recent events, and recent IP observations.
|
||||
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
||||
- [x] Administrators can update the preferred name and synchronize Discord.
|
||||
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
||||
- [x] Administrators can remove an account only after a visible confirmation step.
|
||||
@@ -39,3 +39,4 @@ Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test
|
||||
|
||||
- [Administrator SSO](us-011-admin-sso.md)
|
||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Control Minecraft admission with groups
|
||||
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
||||
tags: [admin, groups, authorization, velocity, security]
|
||||
timestamp: 2026-08-01T22:36:20Z
|
||||
story_id: US-017
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want to organize registered users into access groups, so that server admission can be enabled for selected communities while remaining off by default.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Every registered user implicitly belongs to the protected `everyone` group.
|
||||
- [x] The `everyone` group is created with Minecraft access disabled.
|
||||
- [x] Administrators can create groups with access disabled by default.
|
||||
- [x] Administrators can add and remove users from non-default groups.
|
||||
- [x] Administrators can enable or disable Minecraft admission for each group.
|
||||
- [x] A registered player is admitted when any assigned group has access enabled.
|
||||
- [x] A registered player is denied when none of their groups has access enabled.
|
||||
- [x] Group creation, membership, and access-policy changes are audited.
|
||||
- [x] Users and administrators can inspect the user's effective group assignments.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql)
|
||||
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/auth/test/group-access.test.ts`](../packages/auth/test/group-access.test.ts)
|
||||
- Drizzle migration generation, TypeScript validation, tests, lint, and the production build must pass.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enforce registration at Velocity](us-009-velocity-admission.md)
|
||||
- [Manage users as an administrator](us-013-admin-user-management.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -24,12 +24,14 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
||||
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
|
||||
- Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
|
||||
- Velocity and its API fail closed.
|
||||
- Registered players require at least one enabled access group; the implicit `everyone` group starts disabled.
|
||||
- Group and membership mutations re-check the Keycloak administrator role server-side and are audited.
|
||||
- ORM-parameterized queries are used throughout.
|
||||
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
|
||||
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
|
||||
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
|
||||
- Portal and game login events include approximate network location and VPN/proxy classification when available.
|
||||
- Secrets are excluded from logs and repository configuration.
|
||||
- Structured Pino logging redacts credential fields, and secrets are excluded from logs and repository configuration.
|
||||
|
||||
## Outstanding production requirements
|
||||
|
||||
|
||||
@@ -111,6 +111,10 @@ export function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function enabledAccessGroup<T extends { accessEnabled: boolean }>(assignedGroups: T[]) {
|
||||
return assignedGroups.find((group) => group.accessEnabled) ?? null;
|
||||
}
|
||||
|
||||
export function verifyHashedToken(providedToken: string, expectedHash: string) {
|
||||
const provided = Buffer.from(hashToken(providedToken), "utf8");
|
||||
const expected = Buffer.from(expectedHash, "utf8");
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enabledAccessGroup } from "../src/index";
|
||||
|
||||
describe("group-based admission", () => {
|
||||
it("denies default-off users and allows access when any assigned group is enabled", () => {
|
||||
expect(enabledAccessGroup([{ name: "everyone", accessEnabled: false }])).toBeNull();
|
||||
expect(enabledAccessGroup([
|
||||
{ name: "everyone", accessEnabled: false },
|
||||
{ name: "ops", accessEnabled: true },
|
||||
])).toEqual({ name: "ops", accessEnabled: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "groups" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(50) NOT NULL,
|
||||
"slug" varchar(50) NOT NULL,
|
||||
"description" text,
|
||||
"access_enabled" boolean DEFAULT false NOT NULL,
|
||||
"is_default" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_group_memberships" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"group_id" uuid NOT NULL,
|
||||
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "groups_slug_uidx" ON "groups" USING btree (lower("slug"));--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "groups_one_default_uidx" ON "groups" USING btree ("is_default") WHERE "groups"."is_default" = true;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "user_group_memberships_user_group_uidx" ON "user_group_memberships" USING btree ("user_id","group_id");--> statement-breakpoint
|
||||
CREATE INDEX "user_group_memberships_group_idx" ON "user_group_memberships" USING btree ("group_id");--> statement-breakpoint
|
||||
INSERT INTO "groups" ("name", "slug", "description", "access_enabled", "is_default")
|
||||
VALUES ('everyone', 'everyone', 'Default group containing every registered user.', false, true);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1785605590058,
|
||||
"tag": "0001_silent_ultragirl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1785623198008,
|
||||
"tag": "0002_simple_queen_noir",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export async function findUserBySessionToken(db: Database, tokenHash: string, no
|
||||
id: users.id,
|
||||
discordUserId: users.discordUserId,
|
||||
discordUsername: users.discordUsername,
|
||||
discordGlobalName: users.discordGlobalName,
|
||||
firstName: users.firstName,
|
||||
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||
sessionExpiresAt: sessions.expiresAt,
|
||||
|
||||
@@ -62,6 +62,40 @@ export const users = pgTable(
|
||||
(table) => [uniqueIndex("users_discord_user_id_uidx").on(table.discordUserId)],
|
||||
);
|
||||
|
||||
export const groups = pgTable(
|
||||
"groups",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 50 }).notNull(),
|
||||
slug: varchar("slug", { length: 50 }).notNull(),
|
||||
description: text("description"),
|
||||
accessEnabled: boolean("access_enabled").notNull().default(false),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
...timestamps(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("groups_slug_uidx").on(sql`lower(${table.slug})`),
|
||||
uniqueIndex("groups_one_default_uidx").on(table.isDefault).where(sql`${table.isDefault} = true`),
|
||||
],
|
||||
);
|
||||
|
||||
export const userGroupMemberships = pgTable(
|
||||
"user_group_memberships",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
groupId: uuid("group_id")
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("user_group_memberships_user_group_uidx").on(table.userId, table.groupId),
|
||||
index("user_group_memberships_group_idx").on(table.groupId),
|
||||
],
|
||||
);
|
||||
|
||||
export const minecraftAccounts = pgTable(
|
||||
"minecraft_accounts",
|
||||
{
|
||||
|
||||
@@ -46,6 +46,46 @@ export function formatManagedDiscordNickname(
|
||||
return [...firstName.trim()].slice(0, DISCORD_NICKNAME_LIMIT).join("").trimEnd();
|
||||
}
|
||||
|
||||
export interface DiscordGuildIdentity {
|
||||
id: string;
|
||||
username: string;
|
||||
globalName: string | null;
|
||||
nickname: string | null;
|
||||
}
|
||||
|
||||
export async function getGuildMemberIdentity(
|
||||
input: { guildId: string; discordUserId: string; botToken: string },
|
||||
request: typeof fetch = fetch,
|
||||
): Promise<DiscordGuildIdentity> {
|
||||
const response = await request(
|
||||
`https://discord.com/api/v10/guilds/${input.guildId}/members/${input.discordUserId}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bot ${input.botToken}`,
|
||||
accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Discord guild member lookup failed (${response.status})`);
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
if (!payload || typeof payload !== "object") throw new Error("Discord guild member lookup returned invalid data");
|
||||
const member = payload as { nick?: unknown; user?: unknown };
|
||||
if (!member.user || typeof member.user !== "object") throw new Error("Discord guild member lookup omitted user data");
|
||||
const user = member.user as { id?: unknown; username?: unknown; global_name?: unknown };
|
||||
if (typeof user.id !== "string" || typeof user.username !== "string") {
|
||||
throw new Error("Discord guild member lookup returned invalid user data");
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: typeof user.global_name === "string" ? user.global_name : null,
|
||||
nickname: typeof member.nick === "string" ? member.nick : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateGuildNickname(
|
||||
input: {
|
||||
guildId: string;
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { updateGuildNickname } from "../src/index";
|
||||
import { getGuildMemberIdentity, updateGuildNickname } from "../src/index";
|
||||
|
||||
describe("Discord guild identity", () => {
|
||||
it("reads the member username, global name, nickname, and immutable ID", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
|
||||
nick: "Sam (Notch)",
|
||||
user: { id: "987654321098765432", username: "samcraft", global_name: "Sam" },
|
||||
}), { status: 200, headers: { "content-type": "application/json" } }));
|
||||
|
||||
await expect(getGuildMemberIdentity({
|
||||
guildId: "123456789012345678",
|
||||
discordUserId: "987654321098765432",
|
||||
botToken: "secret",
|
||||
}, request)).resolves.toEqual({
|
||||
id: "987654321098765432",
|
||||
username: "samcraft",
|
||||
globalName: "Sam",
|
||||
nickname: "Sam (Notch)",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Discord nickname updates", () => {
|
||||
it("updates a member in the configured guild using bot authentication", async () => {
|
||||
|
||||
Reference in New Issue
Block a user