feat(admin): streamline group management
This commit is contained in:
@@ -1,16 +1,28 @@
|
||||
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
import { AdminUserTable } from "@/components/admin-user-table";
|
||||
import { GroupPolicyControl } from "@/components/group-policy-control";
|
||||
import { db } from "@/lib/database";
|
||||
import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess, setGroupAnonymizedNetworkAccess } from "../actions";
|
||||
import { isEffectiveGroupMember } from "@/lib/group-management";
|
||||
import { assignUserGroupFromRegistry } from "../../users/actions";
|
||||
import { deleteGroup, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
|
||||
|
||||
const savedMessages: Record<string, string> = {
|
||||
created: "Group created with access disabled.",
|
||||
access: "Group access policy updated.",
|
||||
"network-access": "Group VPN, proxy, and Tor policy updated.",
|
||||
"member-added": "User assigned to the group.",
|
||||
"member-removed": "User returned to the default group.",
|
||||
created: "Group created.",
|
||||
details: "Group details updated.",
|
||||
access: "Minecraft access policy updated.",
|
||||
"network-access": "VPN, proxy, and Tor policy updated.",
|
||||
group: "Member group updated.",
|
||||
};
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
"invalid-group": "Enter a valid name and a description of no more than 500 characters.",
|
||||
"duplicate-group": "A group with that name already exists.",
|
||||
"invalid-group-assignment": "The user or destination group no longer exists. No membership change was applied.",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -20,31 +32,43 @@ export default async function GroupPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ groupId: string }>;
|
||||
searchParams: Promise<{ saved?: string }>;
|
||||
searchParams: Promise<{ error?: string; 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([
|
||||
const [allUsers, allGroups, 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,
|
||||
groupId: userGroupMemberships.groupId,
|
||||
groupName: groups.name,
|
||||
}).from(userGroupMemberships).innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)),
|
||||
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),
|
||||
))
|
||||
.orderBy(users.firstName, users.discordUsername),
|
||||
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 assignmentByUser = new Map(memberships.map((membership) => [membership.userId, membership]));
|
||||
const memberCount = group.isDefault
|
||||
? allUsers.length - assignmentByUser.size
|
||||
: memberships.filter((membership) => membership.groupId === group.id).length;
|
||||
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||
const memberUsers = allUsers.filter((user) => isEffectiveGroupMember(user.id, assignmentByUser, group));
|
||||
const returnTo = `/admin/groups/${group.id}`;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-12">
|
||||
@@ -53,83 +77,57 @@ export default async function GroupPage({
|
||||
<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>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<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>
|
||||
<form action={setGroupAnonymizedNetworkAccess} className="border-l-2 border-accent pl-5">
|
||||
<input name="groupId" type="hidden" value={group.id} />
|
||||
<input name="anonymizedNetworksAllowed" type="hidden" value={group.anonymizedNetworksAllowed ? "no" : "yes"} />
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">VPN / proxy / Tor</p>
|
||||
<p className="mt-2 font-display text-2xl font-black uppercase">{group.anonymizedNetworksAllowed ? "Allowed" : "Denied"}</p>
|
||||
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn exceptions {group.anonymizedNetworksAllowed ? "off" : "on"}</button>
|
||||
</form>
|
||||
<p className="mt-3 max-w-2xl whitespace-pre-line text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
||||
</div>
|
||||
<AdminModalForm
|
||||
action={updateGroupDetails}
|
||||
description={group.isDefault ? "Update the protected default group's description. Its name remains everyone." : "Update the administrator-facing name and description. The internal slug remains stable."}
|
||||
submitLabel="Save details"
|
||||
title={`Edit ${group.name}`}
|
||||
triggerClassName="border border-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider"
|
||||
triggerLabel="Edit group"
|
||||
>
|
||||
<input name="groupId" type="hidden" value={group.id} />
|
||||
<div className="space-y-5">
|
||||
<label className="block 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 read-only:cursor-not-allowed read-only:text-muted" defaultValue={group.name} maxLength={50} name="name" readOnly={group.isDefault} required /></label>
|
||||
<label className="block text-sm font-bold">Description<textarea className="mt-2 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" defaultValue={group.description ?? ""} maxLength={500} name="description" /></label>
|
||||
</div>
|
||||
</AdminModalForm>
|
||||
</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" role="status">{savedMessages[query.saved] ?? "Group updated."}</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>}
|
||||
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[query.error] ?? "The group operation failed."}</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">{memberCount} members</span>
|
||||
</div>
|
||||
{group.isDefault && <p className="border-b border-line bg-panel px-5 py-4 text-sm text-muted">Users belong to <strong className="text-ink">everyone</strong> only while they have no explicit group assignment.</p>}
|
||||
<div className="divide-y divide-line">
|
||||
{allUsers.map((user) => {
|
||||
const assignment = assignmentByUser.get(user.id);
|
||||
const isMember = group.isDefault ? !assignment : assignment?.groupId === group.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>
|
||||
{!isMember && assignment && <p className="mt-1 text-xs text-muted">Currently assigned to {assignment.groupName}</p>}
|
||||
</div>
|
||||
{isMember ? (
|
||||
group.isDefault ? <span className="font-mono text-[9px] font-bold uppercase text-muted">Default assignment</span> : (
|
||||
<form action={removeGroupMember}>
|
||||
<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 text-accent underline underline-offset-4" type="submit">Return to everyone</button>
|
||||
</form>
|
||||
)
|
||||
) : (
|
||||
<form action={group.isDefault ? assignDefaultGroup : 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 text-ink underline underline-offset-4" type="submit">Move to {group.name}</button>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{!allUsers.length && <p className="py-8 text-sm text-muted">No registered users yet.</p>}
|
||||
<section aria-labelledby="group-policy-heading" className="mt-10 border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<div className="border-b border-line pb-4"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Admission controls</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-policy-heading">Group policies</h2></div>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<PolicyDetail description="Controls whether members can connect to Minecraft." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
|
||||
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10" aria-labelledby="group-members-heading">
|
||||
<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">Effective membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-members-heading">Members</h2></div>
|
||||
<span className="font-mono text-xs text-muted">{memberUsers.length} {memberUsers.length === 1 ? "member" : "members"}</span>
|
||||
</div>
|
||||
<p className="border-x border-line bg-panel px-5 py-4 text-sm text-muted">{group.isDefault ? <>These users have no explicit assignment and therefore use <strong className="text-ink">everyone</strong>.</> : <>Choose another group to move a member, or choose <strong className="text-ink">everyone</strong> to remove the member from {group.name}. Every change requires confirmation.</>}</p>
|
||||
<div className="mt-5"><AdminUserTable action={assignUserGroupFromRegistry} assignmentByUser={assignmentByUser} emptyMessage="This group has no effective members." groups={allGroups} returnTo={returnTo} users={memberUsers} /></div>
|
||||
</section>
|
||||
|
||||
{!group.isDefault && (
|
||||
<section className="mt-12 border border-accent bg-panel p-6">
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p>
|
||||
<h2 className="mt-3 font-display text-2xl font-black uppercase">Delete {group.name}</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">Deleting this group returns its {memberCount} {memberCount === 1 ? "member" : "members"} to the protected default group. This cannot be undone.</p>
|
||||
<details className="mt-5">
|
||||
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4">Review deletion</summary>
|
||||
<form action={deleteGroup} className="mt-4 flex flex-wrap items-center gap-4">
|
||||
<input name="groupId" type="hidden" value={group.id} />
|
||||
<input name="confirmDelete" type="hidden" value="yes" />
|
||||
<button className="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Delete group permanently</button>
|
||||
<span className="text-xs text-muted">Members will use everyone immediately.</span>
|
||||
</form>
|
||||
</details>
|
||||
<section className="mt-12 flex flex-col gap-5 border border-accent bg-panel p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Delete {group.name}</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-muted">All {memberUsers.length} effective {memberUsers.length === 1 ? "member" : "members"} will return to everyone.</p></div>
|
||||
<AdminModalForm action={deleteGroup} description={`Permanently delete ${group.name} and return ${memberUsers.length} ${memberUsers.length === 1 ? "member" : "members"} to everyone. This cannot be undone.`} intent="danger" submitLabel="Delete group" title={`Delete ${group.name}?`} triggerClassName="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" triggerLabel="Delete group">
|
||||
<input name="groupId" type="hidden" value={group.id} />
|
||||
<input name="confirmDelete" type="hidden" value="yes" />
|
||||
</AdminModalForm>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyDetail({ children, description, label }: { children: ReactNode; description: string; label: string }) {
|
||||
return <div className="flex items-center justify-between gap-5 border-l-2 border-accent pl-5"><div><h3 className="font-mono text-xs font-bold uppercase">{label}</h3><p className="mt-2 text-xs leading-5 text-muted">{description}</p></div>{children}</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user