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
@@ -1,84 +1,102 @@
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { asc, desc } from "drizzle-orm";
import { asc, count, desc } from "drizzle-orm";
import Link from "next/link";
import { AdminModalForm } from "@/components/admin-modal-form";
import { GroupPolicyControl } from "@/components/group-policy-control";
import { db } from "@/lib/database";
import { createGroup, setGroupAccess } from "./actions";
import { effectiveGroupMemberCount } from "@/lib/group-management";
import { createGroup, setGroupAccess, setGroupAnonymizedNetworkAccess } 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.",
"invalid-group": "Enter a group name and an optional description of no more than 500 characters.",
"duplicate-group": "A group with that name already exists.",
"create-failed": "The group could not be created.",
"unknown-group": "That group no longer exists.",
"invalid-membership": "That membership change was invalid.",
"invalid-delete": "Confirm the group deletion before continuing.",
"protected-group": "The protected default group cannot be deleted.",
};
const savedMessages: Record<string, string> = {
deleted: "Group deleted. Its former members now use the default group.",
access: "Minecraft access policy updated.",
"network-access": "VPN, proxy, and Tor policy updated.",
};
export const dynamic = "force-dynamic";
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
const query = await searchParams;
const [allGroups, memberships, registeredUsers] = await Promise.all([
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),
db.select({ count: count() }).from(users),
]);
const membershipCounts = new Map<string, number>();
for (const membership of memberships) {
membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1);
}
const explicitlyAssignedUsers = memberships.length;
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<header className="border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Admission policy</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
<p className="mt-5 max-w-2xl leading-7 text-muted">Each user has one effective group. Users without an explicit assignment fall back to <strong className="text-ink">everyone</strong>; Minecraft admission follows only that groups access setting.</p>
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<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">One effective group controls Minecraft and VPN access. Every policy change asks for confirmation before it applies.</p>
</div>
<AdminModalForm
action={createGroup}
description="Create a named access group. Both policies start denied unless you explicitly enable them below."
submitLabel="Create group"
title="Add access group"
triggerClassName="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas"
triggerLabel="Add group"
>
<div className="space-y-5">
<label className="block text-sm font-bold">Name<input autoComplete="off" 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="block text-sm font-bold">Description<textarea className="mt-2 min-h-28 w-full resize-y border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
<PolicyCheckbox description="Allow members to connect to Minecraft." label="Minecraft access" name="accessEnabled" />
<PolicyCheckbox description="Allow confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor exception" name="anonymizedNetworksAllowed" />
</div>
</AdminModalForm>
</header>
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errors[query.error] ?? "The group operation failed."}</p>}
{query.saved === "deleted" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">Group deleted. Its former members now use the default group.</p>}
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
<section className="mt-10 grid gap-5 md:grid-cols-2">
{allGroups.map((group) => {
const memberCount = group.isDefault ? registeredUsers.length - explicitlyAssignedUsers : membershipCounts.get(group.id) ?? 0;
return (
<article className="border border-line bg-panel p-6 shadow-[5px_5px_0_var(--color-shadow)]" key={group.id}>
<div className="flex items-start justify-between gap-4">
<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>
<div className="flex flex-col items-end gap-2"><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><span className="font-mono text-[9px] font-bold uppercase text-muted">VPN {group.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div>
</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 Minecraft access and VPN/proxy/Tor exceptions 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>
<div className="mt-9 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<table className="w-full min-w-[760px] border-collapse text-left">
<caption className="sr-only">Access groups and their effective policies</caption>
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4" scope="col">Name</th><th className="p-4" scope="col">Minecraft access</th><th className="p-4" scope="col">VPN access</th><th className="p-4 text-right" scope="col">Users</th></tr>
</thead>
<tbody className="divide-y divide-line">
{allGroups.map((group) => {
const memberCount = effectiveGroupMemberCount(
Number(registeredUsers?.count ?? 0),
memberships.map((membership) => membership.groupId),
group,
);
return (
<tr className="transition-colors hover:bg-canvas/60" key={group.id}>
<th className="p-4 text-left" scope="row">
<Link className="font-display text-xl font-black uppercase underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/groups/${group.id}`}>{group.name}</Link>
{group.isDefault && <span className="ml-3 bg-ink px-2 py-1 font-mono text-[8px] font-bold uppercase text-canvas">Default</span>}
</th>
<td className="p-4"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="Minecraft access" returnLocation="list" /></td>
<td className="p-4"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="VPN / proxy / Tor" returnLocation="list" /></td>
<td className="p-4 text-right font-mono text-sm font-bold">{memberCount}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="mt-4 text-xs leading-5 text-muted">Users without an explicit assignment count toward <strong className="text-ink">everyone</strong>.</p>
</main>
);
}
function PolicyCheckbox({ description, label, name }: { description: string; label: string; name: string }) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4 border border-line bg-canvas p-4">
<span><span className="block font-mono text-xs font-bold uppercase">{label}</span><span className="mt-1 block text-xs leading-5 text-muted">{description}</span></span>
<input className="mt-1 size-5 accent-[var(--color-accent)]" name={name} type="checkbox" value="yes" />
</label>
);
}