feat(admin): streamline group management
CI / validate (push) Successful in 4m16s
Release / release (push) Failing after 9m14s

This commit is contained in:
dmg
2026-08-02 11:06:55 -04:00
parent 71856bb869
commit d4afb71798
24 changed files with 1588 additions and 379 deletions
@@ -14,6 +14,7 @@ import { redirect } from "next/navigation";
import { recordAdminEvent } from "@/lib/audit";
import { requireAdminSession } from "@/lib/auth/require-admin";
import { db } from "@/lib/database";
import { adminGroupReturnPath } from "@/lib/group-management";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
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;
@@ -22,12 +23,6 @@ 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);
@@ -85,20 +80,26 @@ 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 returnTo = formData.get("returnTo");
if (!UUID_PATTERN.test(userId) || !UUID_PATTERN.test(groupId)) redirect(adminGroupReturnPath(returnTo, "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`);
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-membership'))`);
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
await tx.execute(sql`select ${users.id} from ${users} where ${users.id} = ${userId} for update`);
const [[user], [targetGroup]] = await Promise.all([
tx.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
tx.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
]);
if (!user || !targetGroup) throw new Error("User or destination group no longer exists");
const [membership] = await tx.select({ groupId: userGroupMemberships.groupId })
.from(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id)).limit(1);
if (membership && membership.groupId !== targetGroup.id) {
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${membership.groupId} for update`);
}
const [previous] = await tx.select({ id: groups.id, name: groups.name })
.from(userGroupMemberships)
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
@@ -131,9 +132,9 @@ export async function assignUserGroupFromRegistry(formData: FormData) {
});
});
} catch {
redirect(userRegistryPath(search, "error=invalid-group-assignment"));
redirect(adminGroupReturnPath(returnTo, "error=invalid-group-assignment"));
}
redirect(userRegistryPath(search, "saved=group"));
redirect(adminGroupReturnPath(returnTo, "saved=group"));
}
export async function updateUserName(formData: FormData) {