feat(admission): add group VPN exceptions
CI / validate (push) Successful in 5m45s
Release / release (push) Successful in 7m21s

This commit is contained in:
dmg
2026-08-02 10:16:16 -04:00
parent 24808b0f8c
commit 71856bb869
34 changed files with 1820 additions and 138 deletions
@@ -86,7 +86,7 @@ export default async function AdminUserPage({
.orderBy(desc(ipObservations.observedAt))
.limit(100),
discordIdentity(user),
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault })
.from(groups)
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
@@ -208,7 +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>
{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>}
{effectiveGroup ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3"><Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/groups/${effectiveGroup.id}`}>{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</Link><div className="flex gap-2"><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "Access on" : "Access off"}</span><span className="border border-line px-2 py-1 font-mono text-[9px] font-bold uppercase">VPN {effectiveGroup.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div></div> : <p className="mt-4 text-sm text-accent">No effective group configured.</p>}
</section>
<section className="border border-line bg-panel p-6">
@@ -1,12 +1,15 @@
"use server";
import { randomUUID } from "node:crypto";
import {
formatManagedDiscordNickname,
lookupJavaProfile,
updateGuildNickname,
} from "@minecraft-account-manager/minecraft";
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
import { and, eq, isNull, ne } from "drizzle-orm";
import { events, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { getClientIp } from "@minecraft-account-manager/network";
import { and, eq, isNull, ne, sql } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { recordAdminEvent } from "@/lib/audit";
import { requireAdminSession } from "@/lib/auth/require-admin";
@@ -19,6 +22,12 @@ function userPath(userId: string, query?: string) {
return `/admin/users/${encodeURIComponent(userId)}${query ? `?${query}` : ""}`;
}
function userRegistryPath(search: string, result: "saved=group" | "error=invalid-group-assignment") {
const query = new URLSearchParams(result);
if (search) query.set("q", search);
return `/admin/users?${query.toString()}`;
}
async function targetUser(userId: string) {
if (!UUID_PATTERN.test(userId)) return null;
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
@@ -72,6 +81,61 @@ async function recordSyncFailure(
);
}
export async function assignUserGroupFromRegistry(formData: FormData) {
const admin = await requireAdminSession();
const userId = String(formData.get("userId") ?? "");
const groupId = String(formData.get("groupId") ?? "");
const search = String(formData.get("search") ?? "").trim().slice(0, 100);
if (!UUID_PATTERN.test(userId) || !UUID_PATTERN.test(groupId)) redirect(userRegistryPath(search, "error=invalid-group-assignment"));
const [[user], [targetGroup]] = await Promise.all([
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
]);
if (!user || !targetGroup) redirect(userRegistryPath(search, "error=invalid-group-assignment"));
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
try {
await db.transaction(async (tx) => {
await tx.execute(sql`select ${users.id} from ${users} where ${users.id} = ${user.id} for update`);
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);
if (targetGroup.isDefault) {
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
} else {
await tx.insert(userGroupMemberships).values({ userId: user.id, groupId: targetGroup.id })
.onConflictDoUpdate({
target: userGroupMemberships.userId,
set: { groupId: targetGroup.id },
});
}
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.group.assignment-updated",
subject: `user/${user.id}`,
time: new Date(),
data: {
groupId: targetGroup.id,
groupName: targetGroup.name,
previousGroupId: previous?.id ?? null,
previousGroupName: previous?.name ?? "everyone",
adminEmail: admin.email,
adminName: admin.name,
},
ipAddress: ipAddress ?? null,
});
});
} catch {
redirect(userRegistryPath(search, "error=invalid-group-assignment"));
}
redirect(userRegistryPath(search, "saved=group"));
}
export async function updateUserName(formData: FormData) {
const admin = await requireAdminSession();
const userId = String(formData.get("userId") ?? "");
+46 -34
View File
@@ -1,14 +1,16 @@
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { and, asc, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
import Link from "next/link";
import { UserGroupSelect } from "@/components/user-group-select";
import { db } from "@/lib/database";
import { assignUserGroupFromRegistry } from "./actions";
export const dynamic = "force-dynamic";
export default async function AdminUsersPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; error?: string }>;
searchParams: Promise<{ q?: string; error?: string; saved?: string }>;
}) {
const query = await searchParams;
const search = query.q?.trim().slice(0, 100) ?? "";
@@ -31,33 +33,41 @@ export default async function AdminUsersPage({
)
: undefined;
const results = await db
.select({
id: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
discordGlobalName: users.discordGlobalName,
discordUserId: users.discordUserId,
onboardingCompletedAt: users.onboardingCompletedAt,
primaryUsername: minecraftAccounts.username,
accountCount: sql<number>`(
select count(*)::int from ${minecraftAccounts} account_count
where account_count.user_id = ${users.id}
and account_count.deleted_at is null
)`,
})
.from(users)
.leftJoin(
minecraftAccounts,
and(
eq(minecraftAccounts.userId, users.id),
eq(minecraftAccounts.isPrimary, true),
isNull(minecraftAccounts.deletedAt),
),
)
.where(where)
.orderBy(users.firstName, users.discordUsername)
.limit(100);
const [results, allGroups, memberships] = await Promise.all([
db
.select({
id: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
discordGlobalName: users.discordGlobalName,
discordUserId: users.discordUserId,
onboardingCompletedAt: users.onboardingCompletedAt,
primaryUsername: minecraftAccounts.username,
accountCount: sql<number>`(
select count(*)::int from ${minecraftAccounts} account_count
where account_count.user_id = ${users.id}
and account_count.deleted_at is null
)`,
})
.from(users)
.leftJoin(
minecraftAccounts,
and(
eq(minecraftAccounts.userId, users.id),
eq(minecraftAccounts.isPrimary, true),
isNull(minecraftAccounts.deletedAt),
),
)
.where(where)
.orderBy(users.firstName, users.discordUsername)
.limit(100),
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
.from(userGroupMemberships),
]);
const defaultGroup = allGroups.find((group) => group.isDefault);
const groupByUser = new Map(memberships.map((membership) => [membership.userId, membership.groupId]));
return (
<main className="mx-auto max-w-6xl px-6 py-14">
@@ -79,13 +89,14 @@ 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" role="alert">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">{query.error === "invalid-group-assignment" ? "The user or group no longer exists. No group change was applied." : "The requested user could not be found."}</p>}
{query.saved === "group" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">User group updated.</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">
<table className="w-full min-w-[900px] 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" 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>
<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">Group</th><th className="p-4" scope="col">Status</th></tr>
</thead>
<tbody className="divide-y divide-line">
{results.map((user) => (
@@ -94,10 +105,11 @@ export default async function AdminUsersPage({
<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">{defaultGroup ? <UserGroupSelect action={assignUserGroupFromRegistry} effectiveGroupId={groupByUser.get(user.id) ?? defaultGroup.id} groups={allGroups} search={search} userId={user.id} userLabel={user.firstName ?? user.discordUsername} /> : <span className="text-xs text-accent">Default group missing</span>}</td>
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
</tr>
))}
{!results.length && <tr><td className="p-8 text-muted" colSpan={5}>No users match that search.</td></tr>}
{!results.length && <tr><td className="p-8 text-muted" colSpan={6}>No users match that search.</td></tr>}
</tbody>
</table>
</div>