diff --git a/README.md b/README.md index ec308cf..3f81c47 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The token is displayed once and stored only as a SHA-256 hash. - PostgreSQL and Drizzle ORM - Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role - Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, and automatic Discord nickname synchronization -- Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access and VPN/proxy/Tor exception settings apply +- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, and VPN/proxy/Tor exceptions through confirmed group workflows - Deployment-managed Discord guild ID and invite URL - discord.js bot with `/register` and `/account` - Java Edition online-mode accounts only diff --git a/apps/web/package.json b/apps/web/package.json index 13d22cc..2c94e9d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.1", + "@testing-library/react": "^16.3.2", "@types/d3-geo": "^3.1.1", "@types/leaflet": "^1.9.22", "@types/node": "^25.0.3", @@ -37,6 +38,7 @@ "@types/topojson-client": "^3.1.5", "eslint": "^9.39.4", "eslint-config-next": "^16.2.1", + "jsdom": "^30.0.1", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "vitest": "^4.1.0" diff --git a/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx index 61dfc33..f17997b 100644 --- a/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx +++ b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx @@ -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 = { - 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 = { + "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`( + 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 (
@@ -53,83 +77,57 @@ export default async function GroupPage({

Access group

{group.name}

{group.isDefault && Default}
-

{group.description ?? "No description."}

-
-
-
- - -

Minecraft admission

-

{group.accessEnabled ? "Allowed" : "Denied"}

- -
-
- - -

VPN / proxy / Tor

-

{group.anonymizedNetworksAllowed ? "Allowed" : "Denied"}

- -
+

{group.description ?? "No description."}

+ + +
+ +