Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb1c5b6de4 | ||
|
|
9f0832a5b5 | ||
|
|
ae623f5316 | ||
|
|
d4afb71798 | ||
|
|
71856bb869 | ||
|
|
24808b0f8c |
+11
-3
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS dependencies
|
||||
FROM node:22-alpine AS manifests
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -13,8 +13,16 @@ COPY packages/database/package.json ./packages/database/package.json
|
||||
COPY packages/logging/package.json ./packages/logging/package.json
|
||||
COPY packages/minecraft/package.json ./packages/minecraft/package.json
|
||||
COPY packages/network/package.json ./packages/network/package.json
|
||||
|
||||
FROM manifests AS dependencies
|
||||
RUN npm ci
|
||||
|
||||
FROM manifests AS bot-dependencies
|
||||
RUN npm ci --omit=dev --workspace @minecraft-account-manager/discord-bot
|
||||
|
||||
FROM manifests AS migration-dependencies
|
||||
RUN npm ci --omit=dev --workspace @minecraft-account-manager/database
|
||||
|
||||
FROM dependencies AS builder
|
||||
COPY . .
|
||||
RUN npm run build --workspace @minecraft-account-manager/web
|
||||
@@ -36,7 +44,7 @@ USER app
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
|
||||
FROM dependencies AS bot
|
||||
FROM bot-dependencies AS bot
|
||||
ARG VERSION=development
|
||||
LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
@@ -51,7 +59,7 @@ COPY --chown=app:app packages ./packages
|
||||
USER app
|
||||
CMD ["npm", "run", "start", "--workspace", "@minecraft-account-manager/discord-bot"]
|
||||
|
||||
FROM dependencies AS migrate
|
||||
FROM migration-dependencies AS migrate
|
||||
ARG VERSION=development
|
||||
LABEL org.opencontainers.image.title="Minecraft Account Manager Migrations" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
|
||||
@@ -77,11 +77,11 @@ 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 setting applies
|
||||
- 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
|
||||
- Velocity admission checks are fail closed
|
||||
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache
|
||||
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache and group-scoped game-connection exceptions
|
||||
|
||||
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements, and [`docs/accessibility.md`](docs/accessibility.md) for the WCAG-oriented interface review.
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1"
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function AccountPage({
|
||||
.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)))
|
||||
@@ -192,8 +192,8 @@ export default async function AccountPage({
|
||||
</form>
|
||||
<section className="mt-8 border border-line bg-panel p-6">
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Access groups</p>
|
||||
{effectiveGroup ? <div className="mt-4 flex items-center justify-between gap-3"><span className="font-mono text-xs font-bold">{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</span><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></div> : <p className="mt-4 text-sm text-accent">No default access group is configured.</p>}
|
||||
<p className="mt-4 text-xs leading-5 text-muted">Your effective group alone determines Minecraft access.</p>
|
||||
{effectiveGroup ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3"><span className="font-mono text-xs font-bold">{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</span><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 default access group is configured.</p>}
|
||||
<p className="mt-4 text-xs leading-5 text-muted">Your effective group alone determines Minecraft and VPN/proxy/Tor access.</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { appSettings } from "@minecraft-account-manager/database";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appSettings, events } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { parseAdmissionMessages } from "@/lib/admission-settings";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function saveDiscordSettings(formData: FormData) {
|
||||
await requireAdminSession();
|
||||
export async function saveAdmissionSettings(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const messages = parseAdmissionMessages(formData);
|
||||
if (!messages) redirect("/admin/settings?error=invalid-message");
|
||||
|
||||
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
|
||||
|
||||
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
|
||||
redirect("/admin/settings?error=invalid-message");
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(appSettings)
|
||||
.values({
|
||||
id: "default",
|
||||
registrationMessage,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: appSettings.id,
|
||||
set: {
|
||||
registrationMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
await db.transaction(async (tx) => {
|
||||
const [previous] = await tx.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const changedFields = (Object.keys(messages) as Array<keyof typeof messages>)
|
||||
.filter((field) => previous?.[field] !== messages[field]);
|
||||
await tx.insert(appSettings)
|
||||
.values({ id: "default", ...messages })
|
||||
.onConflictDoUpdate({
|
||||
target: appSettings.id,
|
||||
set: { ...messages, updatedAt: new Date() },
|
||||
});
|
||||
if (changedFields.length) {
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.settings.admission-messages-updated",
|
||||
subject: "settings/default",
|
||||
time: new Date(),
|
||||
data: { changedFields, adminEmail: admin.email, adminName: admin.name },
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
redirect("/admin/settings?saved=1");
|
||||
}
|
||||
|
||||
@@ -1,15 +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 } 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.",
|
||||
"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";
|
||||
@@ -19,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">
|
||||
@@ -52,74 +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>
|
||||
<p className="mt-3 max-w-2xl whitespace-pre-line text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
||||
</div>
|
||||
<form action={setGroupAccess} className="border-l-2 border-accent pl-5">
|
||||
<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} />
|
||||
<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>
|
||||
<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>;
|
||||
}
|
||||
|
||||
@@ -1,150 +1,178 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { events, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { events, groups, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { recordAdminSubjectEvent } from "@/lib/audit";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
import { db } from "@/lib/database";
|
||||
import { editableGroupName, groupSlug, validateGroupDetails } from "@/lib/group-management";
|
||||
|
||||
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;
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
|
||||
type ReturnLocation = "list" | "detail";
|
||||
|
||||
function groupPath(groupId: string, query?: string) {
|
||||
return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function returnLocation(formData: FormData): ReturnLocation {
|
||||
return formData.get("returnLocation") === "list" ? "list" : "detail";
|
||||
}
|
||||
|
||||
function operationPath(groupId: string, location: ReturnLocation, query: string) {
|
||||
return location === "list" ? `/admin/groups?${query}` : groupPath(groupId, query);
|
||||
}
|
||||
|
||||
async function auditContext() {
|
||||
const requestHeaders = await headers();
|
||||
return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
}
|
||||
|
||||
function auditData(admin: Admin, data: Record<string, unknown>) {
|
||||
return { ...data, adminEmail: admin.email, adminName: admin.name };
|
||||
}
|
||||
|
||||
export async function createGroup(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
|
||||
const description = String(formData.get("description") ?? "").trim();
|
||||
if (name.length < 1 || name.length > 50 || !SLUG_PATTERN.test(slug) || slug.length > 50 || description.length > 500) {
|
||||
redirect("/admin/groups?error=invalid-group");
|
||||
}
|
||||
const details = validateGroupDetails(formData.get("name"), formData.get("description"));
|
||||
if (!details) redirect("/admin/groups?error=invalid-group");
|
||||
const accessEnabled = formData.get("accessEnabled") === "yes";
|
||||
const anonymizedNetworksAllowed = formData.get("anonymizedNetworksAllowed") === "yes";
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
let group: { id: string } | undefined;
|
||||
let created: { id: string } | null = null;
|
||||
try {
|
||||
[group] = await db.insert(groups).values({
|
||||
name,
|
||||
slug,
|
||||
description: description || null,
|
||||
accessEnabled: false,
|
||||
isDefault: false,
|
||||
}).returning({ id: groups.id });
|
||||
created = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-identity'))`);
|
||||
const [duplicate] = await tx.select({ id: groups.id }).from(groups)
|
||||
.where(sql`lower(${groups.name}) = lower(${details.name})`).limit(1);
|
||||
if (duplicate) return null;
|
||||
const existing = await tx.select({ slug: groups.slug }).from(groups);
|
||||
const slug = groupSlug(details.name, new Set(existing.map((group) => group.slug.toLowerCase())));
|
||||
const [group] = await tx.insert(groups).values({
|
||||
name: details.name,
|
||||
slug,
|
||||
description: details.description || null,
|
||||
accessEnabled,
|
||||
anonymizedNetworksAllowed,
|
||||
isDefault: false,
|
||||
}).returning({ id: groups.id });
|
||||
if (!group) throw new Error("Group insert returned no row");
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.group.created",
|
||||
subject: `group/${group.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, {
|
||||
name: details.name,
|
||||
slug,
|
||||
accessEnabled,
|
||||
anonymizedNetworksAllowed,
|
||||
}),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return group;
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/groups?error=duplicate-group");
|
||||
redirect("/admin/groups?error=create-failed");
|
||||
}
|
||||
if (!group) redirect("/admin/groups?error=create-failed");
|
||||
if (!created) redirect("/admin/groups?error=duplicate-group");
|
||||
redirect(groupPath(created.id, "saved=created"));
|
||||
}
|
||||
|
||||
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.created", {
|
||||
name,
|
||||
slug,
|
||||
accessEnabled: false,
|
||||
export async function updateGroupDetails(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const details = validateGroupDetails(formData.get("name"), formData.get("description"));
|
||||
if (!UUID_PATTERN.test(groupId) || !details) redirect(operationPath(groupId, "detail", "error=invalid-group"));
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-identity'))`);
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||
const [current] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!current) return "missing" as const;
|
||||
const name = editableGroupName(current.name, current.isDefault, details.name);
|
||||
if (!current.isDefault) {
|
||||
const [duplicate] = await tx.select({ id: groups.id }).from(groups)
|
||||
.where(and(sql`lower(${groups.name}) = lower(${name})`, ne(groups.id, current.id))).limit(1);
|
||||
if (duplicate) return "duplicate" as const;
|
||||
}
|
||||
const [updated] = await tx.update(groups).set({ name, description: details.description || null, updatedAt: new Date() })
|
||||
.where(eq(groups.id, current.id)).returning({ id: groups.id });
|
||||
if (!updated) return "missing" as const;
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.group.details-updated",
|
||||
subject: `group/${current.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, {
|
||||
previousName: current.name,
|
||||
name,
|
||||
previousDescription: current.description,
|
||||
description: details.description || null,
|
||||
}),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return "updated" as const;
|
||||
});
|
||||
redirect(groupPath(group.id, "saved=created"));
|
||||
if (result === "missing") redirect("/admin/groups?error=unknown-group");
|
||||
if (result === "duplicate") redirect(groupPath(groupId, "error=duplicate-group"));
|
||||
redirect(groupPath(groupId, "saved=details"));
|
||||
}
|
||||
|
||||
async function updateGroupPolicy(
|
||||
formData: FormData,
|
||||
policy: "access" | "anonymized-networks",
|
||||
) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const location = returnLocation(formData);
|
||||
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
|
||||
const enabled = formData.get("enabled") === "yes";
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const group = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||
const [current] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!current) return null;
|
||||
const update = policy === "access" ? { accessEnabled: enabled } : { anonymizedNetworksAllowed: enabled };
|
||||
const [updated] = await tx.update(groups).set({ ...update, updatedAt: new Date() })
|
||||
.where(eq(groups.id, current.id)).returning({ id: groups.id });
|
||||
if (!updated) return null;
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: policy === "access"
|
||||
? "games.minecraft.account-manager.group.access-updated"
|
||||
: "games.minecraft.account-manager.group.anonymized-network-access-updated",
|
||||
subject: `group/${current.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, {
|
||||
name: current.name,
|
||||
previousEnabled: policy === "access" ? current.accessEnabled : current.anonymizedNetworksAllowed,
|
||||
enabled,
|
||||
}),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return current;
|
||||
});
|
||||
if (!group) redirect("/admin/groups?error=unknown-group");
|
||||
redirect(operationPath(group.id, location, `saved=${policy === "access" ? "access" : "network-access"}`));
|
||||
}
|
||||
|
||||
export async function setGroupAccess(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const accessEnabled = formData.get("accessEnabled") === "yes";
|
||||
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
|
||||
|
||||
const [group] = await db.update(groups).set({ accessEnabled, updatedAt: new Date() })
|
||||
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
|
||||
if (!group) redirect("/admin/groups?error=unknown-group");
|
||||
|
||||
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.access-updated", {
|
||||
name: group.name,
|
||||
accessEnabled,
|
||||
});
|
||||
redirect(groupPath(group.id, "saved=access"));
|
||||
return updateGroupPolicy(formData, "access");
|
||||
}
|
||||
|
||||
export async function addGroupMember(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const userId = String(formData.get("userId") ?? "");
|
||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
||||
|
||||
const [[group], [user]] = await Promise.all([
|
||||
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
||||
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
||||
]);
|
||||
if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership");
|
||||
|
||||
const previousGroup = await db.transaction(async (tx) => {
|
||||
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);
|
||||
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
||||
await tx.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id });
|
||||
return previous ?? null;
|
||||
});
|
||||
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
previousGroupId: previousGroup?.id ?? null,
|
||||
previousGroupName: previousGroup?.name ?? "everyone",
|
||||
});
|
||||
redirect(groupPath(group.id, "saved=member-added"));
|
||||
}
|
||||
|
||||
export async function removeGroupMember(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const userId = String(formData.get("userId") ?? "");
|
||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
||||
|
||||
const [group] = await db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
|
||||
.from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!group || group.isDefault) redirect("/admin/groups?error=invalid-membership");
|
||||
|
||||
await db.delete(userGroupMemberships).where(and(
|
||||
eq(userGroupMemberships.groupId, group.id),
|
||||
eq(userGroupMemberships.userId, userId),
|
||||
));
|
||||
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.assignment-removed", {
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
fallbackGroup: "everyone",
|
||||
});
|
||||
redirect(groupPath(group.id, "saved=member-removed"));
|
||||
}
|
||||
|
||||
export async function assignDefaultGroup(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const userId = String(formData.get("userId") ?? "");
|
||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
||||
|
||||
const [[defaultGroup], [user]] = await Promise.all([
|
||||
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
||||
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
||||
]);
|
||||
if (!defaultGroup?.isDefault || !user) redirect("/admin/groups?error=invalid-membership");
|
||||
|
||||
const [previous] = await db
|
||||
.select({ id: groups.id, name: groups.name })
|
||||
.from(userGroupMemberships)
|
||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||
.where(eq(userGroupMemberships.userId, user.id))
|
||||
.limit(1);
|
||||
await db.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
||||
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
|
||||
groupId: defaultGroup.id,
|
||||
groupName: defaultGroup.name,
|
||||
previousGroupId: previous?.id ?? null,
|
||||
previousGroupName: previous?.name ?? null,
|
||||
});
|
||||
redirect(groupPath(defaultGroup.id, "saved=member-added"));
|
||||
export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
|
||||
return updateGroupPolicy(formData, "anonymized-networks");
|
||||
}
|
||||
|
||||
export async function deleteGroup(formData: FormData) {
|
||||
@@ -152,40 +180,32 @@ export async function deleteGroup(formData: FormData) {
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const confirmed = formData.get("confirmDelete") === "yes";
|
||||
if (!UUID_PATTERN.test(groupId) || !confirmed) redirect("/admin/groups?error=invalid-delete");
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
const [group] = await tx
|
||||
.select({ id: groups.id, name: groups.name, slug: groups.slug, isDefault: groups.isDefault })
|
||||
.from(groups)
|
||||
.where(eq(groups.id, groupId))
|
||||
.limit(1);
|
||||
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`);
|
||||
const [group] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!group || group.isDefault) return null;
|
||||
const members = await tx
|
||||
.select({ userId: userGroupMemberships.userId })
|
||||
.from(userGroupMemberships)
|
||||
.where(eq(userGroupMemberships.groupId, group.id));
|
||||
const members = await tx.select({ userId: userGroupMemberships.userId })
|
||||
.from(userGroupMemberships).where(eq(userGroupMemberships.groupId, group.id));
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.group.deleted",
|
||||
subject: `group/${group.id}`,
|
||||
time: new Date(),
|
||||
data: {
|
||||
data: auditData(admin, {
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
affectedUsers: members.length,
|
||||
fallbackGroup: "everyone",
|
||||
adminEmail: admin.email,
|
||||
adminName: admin.name,
|
||||
},
|
||||
}),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
await tx.delete(groups).where(eq(groups.id, group.id));
|
||||
return group;
|
||||
});
|
||||
if (!deleted) redirect("/admin/groups?error=protected-group");
|
||||
|
||||
redirect("/admin/groups?saved=deleted");
|
||||
}
|
||||
|
||||
@@ -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 group’s 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>
|
||||
<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>
|
||||
</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 access 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
|
||||
import { db } from "@/lib/database";
|
||||
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
|
||||
import { parseUserLocation } from "@/lib/user-location-map";
|
||||
import { parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -43,7 +43,7 @@ export default async function AdminDashboardPage() {
|
||||
name: users.firstName,
|
||||
discordUsername: users.discordUsername,
|
||||
primaryUsername: minecraftAccounts.username,
|
||||
classification: ipObservations.classification,
|
||||
classification: ipIntelligence.classification,
|
||||
source: ipObservations.source,
|
||||
observedAt: ipObservations.observedAt,
|
||||
intelligence: ipIntelligence.rawResponse,
|
||||
@@ -108,6 +108,7 @@ export default async function AdminDashboardPage() {
|
||||
const locations = locationRows.flatMap((row): UserMapLocation[] => {
|
||||
const parsed = parseUserLocation(row.intelligence);
|
||||
if (!parsed || !row.userId) return [];
|
||||
const network = parseUserNetwork(row.intelligence);
|
||||
return [{
|
||||
userId: row.userId,
|
||||
name: row.name ?? row.discordUsername,
|
||||
@@ -117,6 +118,10 @@ export default async function AdminDashboardPage() {
|
||||
longitude: parsed.longitude,
|
||||
location: parsed.label,
|
||||
classification: row.classification,
|
||||
networkProvider: network.provider,
|
||||
networkAsn: network.asn,
|
||||
connectionType: network.connectionType,
|
||||
proxy: network.proxy,
|
||||
source: row.source,
|
||||
observedAt: row.observedAt,
|
||||
}];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { appSettings } from "@minecraft-account-manager/database";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DEFAULT_ADMISSION_MESSAGES } from "@/lib/admission-settings";
|
||||
import { db } from "@/lib/database";
|
||||
import { saveDiscordSettings } from "../actions";
|
||||
import { saveAdmissionSettings } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,7 +13,11 @@ export default async function SettingsPage({
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
|
||||
const messages = {
|
||||
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
||||
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
||||
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
||||
};
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
||||
|
||||
@@ -29,23 +34,50 @@ export default async function SettingsPage({
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
||||
<form action={saveAdmissionSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
||||
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">Settings saved</p>}
|
||||
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm" role="alert">Check the configuration value and try again.</p>}
|
||||
|
||||
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
|
||||
<textarea
|
||||
className="mt-3 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
|
||||
defaultValue={message}
|
||||
id="registrationMessage"
|
||||
maxLength={500}
|
||||
minLength={10}
|
||||
name="registrationMessage"
|
||||
required
|
||||
/>
|
||||
<fieldset className="space-y-7">
|
||||
<legend className="font-display text-2xl font-black uppercase">Minecraft denial messages</legend>
|
||||
<p className="text-sm leading-6 text-muted">Each plain-text message is returned for one admission outcome. Messages must be between 10 and 500 characters.</p>
|
||||
<AdmissionMessageField description="Shown when the Minecraft identity is not registered." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
|
||||
<AdmissionMessageField description="Shown when the effective group has Minecraft access disabled." label="Group access disabled" name="groupAccessDeniedMessage" value={messages.groupAccessDeniedMessage} />
|
||||
<AdmissionMessageField description="Shown for VPN, proxy, or Tor connections when the effective group has no exception." label="VPN, proxy, or Tor denied" name="vpnDeniedMessage" value={messages.vpnDeniedMessage} />
|
||||
</fieldset>
|
||||
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AdmissionMessageField({
|
||||
description,
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
description: string;
|
||||
label: string;
|
||||
name: "registrationMessage" | "groupAccessDeniedMessage" | "vpnDeniedMessage";
|
||||
value: string;
|
||||
}) {
|
||||
const descriptionId = `${name}-description`;
|
||||
return (
|
||||
<div>
|
||||
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor={name}>{label}</label>
|
||||
<p className="mt-2 text-xs leading-5 text-muted" id={descriptionId}>{description}</p>
|
||||
<textarea
|
||||
aria-describedby={descriptionId}
|
||||
className="mt-3 min-h-28 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
|
||||
defaultValue={value}
|
||||
id={name}
|
||||
maxLength={500}
|
||||
minLength={10}
|
||||
name={name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,16 +1,20 @@
|
||||
"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";
|
||||
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;
|
||||
@@ -72,6 +76,67 @@ 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 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 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))
|
||||
.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(adminGroupReturnPath(returnTo, "error=invalid-group-assignment"));
|
||||
}
|
||||
redirect(adminGroupReturnPath(returnTo, "saved=group"));
|
||||
}
|
||||
|
||||
export async function updateUserName(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const userId = String(formData.get("userId") ?? "");
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, asc, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||
import { AdminUserTable } from "@/components/admin-user-table";
|
||||
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 +32,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 assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||
const returnTo = `/admin/users${search ? `?${new URLSearchParams({ q: search }).toString()}` : ""}`;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
@@ -79,27 +88,11 @@ 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">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{results.map((user) => (
|
||||
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
|
||||
<th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
|
||||
<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"><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>}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-8">
|
||||
<AdminUserTable action={assignUserGroupFromRegistry} assignmentByUser={assignmentByUser} emptyMessage="No users match that search." groups={allGroups} returnTo={returnTo} users={results} />
|
||||
</div>
|
||||
<p className="mt-4 font-mono text-[9px] uppercase tracking-widest text-muted">Showing up to 100 users</p>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||
import { gameAdmissionDenialReason, isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
|
||||
import {
|
||||
appSettings,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES } from "@/lib/admission-settings";
|
||||
import { db } from "@/lib/database";
|
||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
@@ -20,7 +21,6 @@ import { logger } from "@/lib/logger";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
|
||||
const MAX_CLOCK_SKEW_MS = 45_000;
|
||||
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
|
||||
|
||||
function methodNotAllowed(request: Request) {
|
||||
const response = problemResponse(problemDetails(
|
||||
@@ -111,35 +111,13 @@ async function handleVelocityAccess(request: Request) {
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const denialMessage = settings?.registrationMessage ?? DEFAULT_DENIAL_MESSAGE;
|
||||
const admissionMessages = {
|
||||
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
||||
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
||||
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
||||
};
|
||||
|
||||
let [knownAccount] = await db
|
||||
.select({ id: minecraftAccounts.id })
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(minecraftAccounts.minecraftUuid, input.minecraftUuid),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!knownAccount) {
|
||||
[knownAccount] = await db
|
||||
.select({ id: minecraftAccounts.id })
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
isNull(minecraftAccounts.minecraftUuid),
|
||||
sql`lower(${minecraftAccounts.username}) = lower(${input.username})`,
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
const intelligence = knownAccount
|
||||
? await getIpIntelligence(input.ipAddress)
|
||||
: { classification: "unknown" as const, provider: null };
|
||||
const intelligence = await getIpIntelligence(input.ipAddress);
|
||||
const auditIpData = toAuditIpData(intelligence);
|
||||
|
||||
const decision = await db.transaction(async (tx) => {
|
||||
@@ -209,23 +187,24 @@ async function handleVelocityAccess(request: Request) {
|
||||
classification: intelligence.classification,
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return { allowed: false as const, message: denialMessage };
|
||||
return { allowed: false as const, message: admissionDenialMessage("not_registered", admissionMessages) };
|
||||
}
|
||||
|
||||
const [explicitGroup] = await tx
|
||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
|
||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
||||
.from(userGroupMemberships)
|
||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||
.where(eq(userGroupMemberships.userId, account.userId))
|
||||
.limit(1);
|
||||
const [defaultGroup] = await tx
|
||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
|
||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
||||
.from(groups)
|
||||
.where(eq(groups.isDefault, true))
|
||||
.limit(1);
|
||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
|
||||
|
||||
if (!effectiveGroup?.accessEnabled) {
|
||||
const denialReason = gameAdmissionDenialReason(effectiveGroup ?? null, intelligence.classification);
|
||||
if (denialReason) {
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
@@ -235,7 +214,9 @@ async function handleVelocityAccess(request: Request) {
|
||||
actorUserId: account.userId,
|
||||
data: {
|
||||
username: input.username,
|
||||
reason: "group_access_disabled",
|
||||
reason: denialReason,
|
||||
accessGroup: effectiveGroup?.name ?? null,
|
||||
accessGroupId: effectiveGroup?.id ?? null,
|
||||
ipIntelligence: auditIpData,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
@@ -251,9 +232,14 @@ async function handleVelocityAccess(request: Request) {
|
||||
classification: intelligence.classification,
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return { allowed: false as const, message: "Your account group does not currently have server access." };
|
||||
return {
|
||||
allowed: false as const,
|
||||
message: admissionDenialMessage(denialReason, admissionMessages),
|
||||
};
|
||||
}
|
||||
|
||||
if (!effectiveGroup) throw new Error("Effective access group is unavailable after admission approval");
|
||||
|
||||
if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) {
|
||||
await tx
|
||||
.update(minecraftAccounts)
|
||||
@@ -304,6 +290,7 @@ async function handleVelocityAccess(request: Request) {
|
||||
uuidBackfilled: account.minecraftUuid === null,
|
||||
ipIntelligence: auditIpData,
|
||||
accessGroup: effectiveGroup.name,
|
||||
accessGroupId: effectiveGroup.id,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AdminModalForm } from "./admin-modal-form";
|
||||
|
||||
beforeEach(() => {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
});
|
||||
|
||||
describe("AdminModalForm", () => {
|
||||
it("renders an accessible trigger, labelled dialog, cancellation, and pending-capable submit control", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AdminModalForm
|
||||
action={async () => undefined}
|
||||
description="Review this policy change before applying it."
|
||||
submitLabel="Apply policy"
|
||||
title="Change access policy"
|
||||
triggerLabel="Change"
|
||||
>
|
||||
<input name="groupId" type="hidden" value="group-one" />
|
||||
</AdminModalForm>,
|
||||
);
|
||||
expect(markup).toContain("Change access policy");
|
||||
expect(markup).toContain("Review this policy change before applying it.");
|
||||
expect(markup).toContain("<dialog");
|
||||
expect(markup).toContain("aria-haspopup=\"dialog\"");
|
||||
expect(markup).toContain("Cancel");
|
||||
expect(markup).toContain("Apply policy");
|
||||
});
|
||||
|
||||
it("opens, cancels, and prevents dismissal while the action is pending", async () => {
|
||||
let finishAction!: () => void;
|
||||
const action = vi.fn(() => new Promise<void>((resolve) => { finishAction = resolve; }));
|
||||
render(<AdminModalForm action={action} description="Confirm it." submitLabel="Apply policy" title="Change access policy" triggerLabel="Change" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change" }));
|
||||
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
|
||||
expect(dialog.open).toBe(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply policy" }));
|
||||
await waitFor(() => expect(action).toHaveBeenCalledOnce());
|
||||
expect((screen.getByRole("button", { name: "Change" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const cancelEvent = new Event("cancel", { bubbles: false, cancelable: true });
|
||||
dialog.dispatchEvent(cancelEvent);
|
||||
expect(cancelEvent.defaultPrevented).toBe(true);
|
||||
expect(dialog.open).toBe(true);
|
||||
|
||||
finishAction();
|
||||
await waitFor(() => expect((screen.getByRole("button", { name: "Change" }) as HTMLButtonElement).disabled).toBe(false));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(dialog.open).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
export function AdminModalForm({
|
||||
action,
|
||||
children,
|
||||
description,
|
||||
intent = "default",
|
||||
submitLabel,
|
||||
title,
|
||||
triggerClassName,
|
||||
triggerLabel,
|
||||
triggerPressed,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
children?: ReactNode;
|
||||
description: string;
|
||||
intent?: "default" | "danger";
|
||||
submitLabel: string;
|
||||
title: string;
|
||||
triggerClassName?: string;
|
||||
triggerLabel: string;
|
||||
triggerPressed?: boolean;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-haspopup="dialog"
|
||||
aria-pressed={triggerPressed}
|
||||
className={triggerClassName ?? "font-mono text-[10px] font-bold uppercase underline underline-offset-4"}
|
||||
disabled={submitting}
|
||||
onClick={() => dialogRef.current?.showModal()}
|
||||
type="button"
|
||||
>
|
||||
{triggerLabel}
|
||||
</button>
|
||||
<dialog
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
className="admin-modal m-auto w-[min(92vw,36rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm operation</p>
|
||||
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>{title}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>{description}</p>
|
||||
{children && <div className="mt-6">{children}</div>}
|
||||
<ModalActions dialogRef={dialogRef} intent={intent} onPendingChange={setSubmitting} submitLabel={submitLabel} />
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalActions({
|
||||
dialogRef,
|
||||
intent,
|
||||
onPendingChange,
|
||||
submitLabel,
|
||||
}: {
|
||||
dialogRef: RefObject<HTMLDialogElement | null>;
|
||||
intent: "default" | "danger";
|
||||
onPendingChange: (pending: boolean) => void;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
const observedPending = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pending) {
|
||||
observedPending.current = true;
|
||||
onPendingChange(true);
|
||||
} else if (observedPending.current) {
|
||||
observedPending.current = false;
|
||||
onPendingChange(false);
|
||||
}
|
||||
}, [onPendingChange, pending]);
|
||||
return (
|
||||
<div className="mt-8 flex flex-wrap justify-end gap-3 border-t border-line pt-5">
|
||||
<button
|
||||
className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider disabled:opacity-50"
|
||||
disabled={pending}
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className={`px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas disabled:cursor-wait disabled:opacity-60 ${intent === "danger" ? "bg-accent" : "bg-ink"}`}
|
||||
disabled={pending}
|
||||
type="submit"
|
||||
>
|
||||
{pending ? "Applying…" : submitLabel}
|
||||
</button>
|
||||
<span aria-live="polite" className="sr-only">{pending ? "Operation in progress." : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AdminUserTable } from "./admin-user-table";
|
||||
|
||||
describe("AdminUserTable", () => {
|
||||
it("renders reusable identity and confirmed group controls", () => {
|
||||
const markup = renderToStaticMarkup(<AdminUserTable
|
||||
action={async () => undefined}
|
||||
assignmentByUser={{ user1: "ops" }}
|
||||
emptyMessage="No members."
|
||||
groups={[{ id: "everyone", name: "everyone", isDefault: true }, { id: "ops", name: "Ops", isDefault: false }]}
|
||||
returnTo="/admin/groups/11111111-1111-4111-8111-111111111111"
|
||||
users={[{
|
||||
id: "user1",
|
||||
firstName: "Alex",
|
||||
discordUsername: "alex",
|
||||
discordGlobalName: "Alex Global",
|
||||
discordUserId: "123",
|
||||
onboardingCompletedAt: new Date("2026-08-01T00:00:00Z"),
|
||||
primaryUsername: "AlexMC",
|
||||
accountCount: 2,
|
||||
}]}
|
||||
/>);
|
||||
expect(markup).toContain("Alex Global");
|
||||
expect(markup).toContain("AlexMC");
|
||||
expect(markup).toContain("Accounts");
|
||||
expect(markup).toContain("Group for Alex");
|
||||
expect(markup).toContain("Confirm move");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import { UserGroupSelect } from "./user-group-select";
|
||||
|
||||
export interface AdminUserRow {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
discordUsername: string;
|
||||
discordGlobalName: string | null;
|
||||
discordUserId: string;
|
||||
onboardingCompletedAt: Date | null;
|
||||
primaryUsername: string | null;
|
||||
accountCount: number;
|
||||
}
|
||||
|
||||
export function AdminUserTable({
|
||||
action,
|
||||
assignmentByUser,
|
||||
emptyMessage,
|
||||
groups,
|
||||
returnTo,
|
||||
users,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
assignmentByUser: Record<string, string>;
|
||||
emptyMessage: string;
|
||||
groups: Array<{ id: string; name: string; isDefault: boolean }>;
|
||||
returnTo: string;
|
||||
users: AdminUserRow[];
|
||||
}) {
|
||||
const defaultGroup = groups.find((group) => group.isDefault);
|
||||
return (
|
||||
<div className="overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[900px] border-collapse text-left">
|
||||
<caption className="sr-only">Registered portal users and effective groups</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">Group</th><th className="p-4" scope="col">Status</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{users.map((user) => (
|
||||
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
|
||||
<th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
|
||||
<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={action} effectiveGroupId={assignmentByUser[user.id] ?? defaultGroup.id} groups={groups} returnTo={returnTo} 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>
|
||||
))}
|
||||
{!users.length && <tr><td className="p-8 text-muted" colSpan={6}>{emptyMessage}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AdminModalForm } from "./admin-modal-form";
|
||||
|
||||
export function GroupPolicyControl({
|
||||
action,
|
||||
enabled,
|
||||
groupId,
|
||||
groupName,
|
||||
memberCount,
|
||||
policy,
|
||||
returnLocation,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
enabled: boolean;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
memberCount: number;
|
||||
policy: string;
|
||||
returnLocation: "list" | "detail";
|
||||
}) {
|
||||
const nextState = enabled ? "deny" : "allow";
|
||||
return (
|
||||
<AdminModalForm
|
||||
action={action}
|
||||
description={`${nextState === "allow" ? "Allow" : "Deny"} ${policy.toLowerCase()} for ${memberCount} effective ${memberCount === 1 ? "member" : "members"} of ${groupName}.`}
|
||||
submitLabel={`${nextState === "allow" ? "Allow" : "Deny"} access`}
|
||||
title={`${nextState === "allow" ? "Allow" : "Deny"} ${policy}?`}
|
||||
triggerClassName={`min-w-24 border px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider ${enabled ? "border-signal bg-signal text-ink" : "border-accent bg-transparent text-accent"}`}
|
||||
triggerLabel={enabled ? "Allowed" : "Denied"}
|
||||
triggerPressed={enabled}
|
||||
>
|
||||
<input name="groupId" type="hidden" value={groupId} />
|
||||
<input name="enabled" type="hidden" value={enabled ? "no" : "yes"} />
|
||||
<input name="returnLocation" type="hidden" value={returnLocation} />
|
||||
</AdminModalForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { UserGroupSelect } from "./user-group-select";
|
||||
|
||||
const groups = [{ id: "group-everyone", name: "everyone" }, { id: "group-ops", name: "Ops" }];
|
||||
|
||||
beforeEach(() => {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
});
|
||||
|
||||
describe("UserGroupSelect", () => {
|
||||
it("renders the effective group and preserves the return path", () => {
|
||||
const markup = renderToStaticMarkup(<UserGroupSelect
|
||||
action={async () => undefined}
|
||||
effectiveGroupId="group-ops"
|
||||
groups={groups}
|
||||
returnTo="/admin/users?q=alex%20smith"
|
||||
userId="user-one"
|
||||
userLabel="Alex"
|
||||
/>);
|
||||
|
||||
expect(markup).toContain('aria-label="Group for Alex"');
|
||||
expect(markup).toContain('<option value="group-ops" selected="">Ops</option>');
|
||||
expect(markup).toContain('<input type="hidden" name="returnTo" value="/admin/users?q=alex%20smith"/>');
|
||||
expect(markup).toContain("Changing this selection opens a confirmation dialog.");
|
||||
expect(markup).toContain("Confirm move");
|
||||
});
|
||||
|
||||
it("requires confirmation and restores the effective group when cancelled", () => {
|
||||
render(<UserGroupSelect action={async () => undefined} effectiveGroupId="group-ops" groups={groups} returnTo="/admin/users" userId="user-one" userLabel="Alex" />);
|
||||
const select = screen.getByRole("combobox", { name: "Group for Alex" }) as HTMLSelectElement;
|
||||
|
||||
fireEvent.change(select, { target: { value: "group-everyone" } });
|
||||
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(select.value).toBe("group-everyone");
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(screen.getByText(/from/).textContent).toContain("Ops");
|
||||
expect(screen.getByText(/from/).textContent).toContain("everyone");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(dialog.open).toBe(false);
|
||||
expect(select.value).toBe("group-ops");
|
||||
expect(select.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
export function UserGroupSelect({
|
||||
action,
|
||||
effectiveGroupId,
|
||||
groups,
|
||||
returnTo,
|
||||
userId,
|
||||
userLabel,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
effectiveGroupId: string;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
returnTo: string;
|
||||
userId: string;
|
||||
userLabel: string;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const helpId = useId();
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(effectiveGroupId);
|
||||
const [proposedGroupId, setProposedGroupId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const currentGroup = groups.find((group) => group.id === effectiveGroupId);
|
||||
const proposedGroup = groups.find((group) => group.id === proposedGroupId);
|
||||
|
||||
function resetSelection() {
|
||||
setSelectedGroupId(effectiveGroupId);
|
||||
setProposedGroupId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only" id={helpId}>Changing this selection opens a confirmation dialog.</span>
|
||||
<select
|
||||
aria-describedby={helpId}
|
||||
aria-label={`Group for ${userLabel}`}
|
||||
className="max-w-44 border border-line bg-canvas px-3 py-2 font-mono text-xs outline-none focus:border-accent disabled:cursor-wait disabled:opacity-60"
|
||||
disabled={proposedGroupId !== null || submitting}
|
||||
onChange={(event) => {
|
||||
const nextGroupId = event.currentTarget.value;
|
||||
if (nextGroupId === effectiveGroupId) return;
|
||||
setSelectedGroupId(nextGroupId);
|
||||
setProposedGroupId(nextGroupId);
|
||||
dialogRef.current?.showModal();
|
||||
}}
|
||||
value={selectedGroupId}
|
||||
>
|
||||
{groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||
</select>
|
||||
<dialog
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
className="admin-modal m-auto w-[min(92vw,34rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||
onClose={() => { if (!submitting) resetSelection(); }}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
|
||||
<input name="userId" type="hidden" value={userId} />
|
||||
<input name="groupId" type="hidden" value={proposedGroupId ?? effectiveGroupId} />
|
||||
<input name="returnTo" type="hidden" value={returnTo} />
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm membership</p>
|
||||
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>Move {userLabel}?</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>
|
||||
Change the effective group from <strong className="text-ink">{currentGroup?.name ?? "unknown"}</strong> to <strong className="text-ink">{proposedGroup?.name ?? "unknown"}</strong>. Their access policy changes immediately.
|
||||
</p>
|
||||
<AssignmentActions dialogRef={dialogRef} onPendingChange={setSubmitting} />
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentActions({ dialogRef, onPendingChange }: { dialogRef: RefObject<HTMLDialogElement | null>; onPendingChange: (pending: boolean) => void }) {
|
||||
const { pending } = useFormStatus();
|
||||
const observedPending = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pending) {
|
||||
observedPending.current = true;
|
||||
onPendingChange(true);
|
||||
} else if (observedPending.current) {
|
||||
observedPending.current = false;
|
||||
onPendingChange(false);
|
||||
}
|
||||
}, [onPendingChange, pending]);
|
||||
return (
|
||||
<div className="mt-8 flex justify-end gap-3 border-t border-line pt-5">
|
||||
<button className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase" disabled={pending} onClick={() => dialogRef.current?.close()} type="button">Cancel</button>
|
||||
<button className="bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Moving…" : "Confirm move"}</button>
|
||||
<span aria-live="polite" className="sr-only">{pending ? "Group change in progress." : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,10 @@ describe("UserWorldMap", () => {
|
||||
longitude: -122.0775,
|
||||
location: "Mountain View, California, US",
|
||||
classification: "clear",
|
||||
networkProvider: "Comcast Cable Communications, LLC",
|
||||
networkAsn: "AS7922",
|
||||
connectionType: "Residential",
|
||||
proxy: false,
|
||||
source: "game",
|
||||
observedAt: new Date("2026-08-01T12:00:00Z"),
|
||||
}, {
|
||||
@@ -23,7 +27,11 @@ describe("UserWorldMap", () => {
|
||||
latitude: 37.4057,
|
||||
longitude: -122.0774,
|
||||
location: "Mountain View, California, US",
|
||||
classification: "clear",
|
||||
classification: "vpn",
|
||||
networkProvider: "Proton AG",
|
||||
networkAsn: "AS62371",
|
||||
connectionType: "VPN",
|
||||
proxy: true,
|
||||
source: "web",
|
||||
observedAt: new Date("2026-08-01T13:00:00Z"),
|
||||
}]} unavailableCount={2} />);
|
||||
@@ -36,8 +44,16 @@ describe("UserWorldMap", () => {
|
||||
expect(markup).toContain("Alex (AlexMC)");
|
||||
expect(markup).toContain("2 users near Mountain View, California, US");
|
||||
expect(markup).toMatch(/<text[^>]*>2<\/text>/);
|
||||
expect(markup).toContain('<details class="mt-5 border-t border-line pt-4" id="map-location-list" open="">');
|
||||
expect(markup).toContain('<details class="mt-5 border-t border-line pt-4" id="map-location-list">');
|
||||
expect(markup).not.toContain('id="map-location-list" open');
|
||||
expect(markup).toContain("Mountain View, California, US");
|
||||
expect(markup).toContain("Comcast Cable Communications, LLC");
|
||||
expect(markup).toContain("AS7922");
|
||||
expect(markup).toContain("Residential");
|
||||
expect(markup).toContain("Proton AG");
|
||||
expect(markup).toContain(">Proxy/VPN<");
|
||||
expect(markup).toContain(">Yes<");
|
||||
expect(markup).toContain(">No<");
|
||||
expect(markup).toContain("World overview");
|
||||
expect(markup).toContain("Interactive OpenStreetMap");
|
||||
expect(markup).toContain("OpenStreetMap, which receives your IP address");
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface UserMapLocation {
|
||||
longitude: number;
|
||||
location: string;
|
||||
classification: string;
|
||||
networkProvider: string | null;
|
||||
networkAsn: string | null;
|
||||
connectionType: string | null;
|
||||
proxy: boolean | null;
|
||||
source: string;
|
||||
observedAt: Date;
|
||||
}
|
||||
@@ -95,15 +99,15 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM
|
||||
</MapViewToggle>
|
||||
<p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p>
|
||||
|
||||
<details className="mt-5 border-t border-line pt-4" id="map-location-list" open={locationGroups.some((group) => group.count > 1)}>
|
||||
<details className="mt-5 border-t border-line pt-4" id="map-location-list">
|
||||
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full min-w-[680px] border-collapse text-left text-xs">
|
||||
<caption className="sr-only">Latest approximate registered-user locations</caption>
|
||||
<thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
|
||||
<table className="w-full min-w-[980px] border-collapse text-left text-xs">
|
||||
<caption className="sr-only">Latest approximate registered-user locations and enriched network details</caption>
|
||||
<thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Connection</th><th className="p-3" scope="col">Proxy/VPN</th><th className="p-3" scope="col">Risk</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.nickname}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
|
||||
{!locations.length && <tr><td className="py-6 text-muted" colSpan={5}>No user observations currently include valid coordinates.</td></tr>}
|
||||
{locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.nickname}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3"><span className="block">{user.networkProvider ?? "Unknown"}</span>{user.networkAsn && <span className="mt-1 block font-mono text-[9px] text-muted">{user.networkAsn}</span>}</td><td className="p-3">{user.connectionType ?? "Unknown"}</td><td className="p-3 font-mono font-bold uppercase">{user.proxy === null ? "Unknown" : user.proxy ? "Yes" : "No"}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
|
||||
{!locations.length && <tr><td className="py-6 text-muted" colSpan={8}>No user observations currently include valid coordinates.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, parseAdmissionMessages } from "./admission-settings";
|
||||
|
||||
describe("admission message settings", () => {
|
||||
it("normalizes three independently configured denial messages", () => {
|
||||
const formData = new FormData();
|
||||
formData.set("registrationMessage", " Register your account before joining. ");
|
||||
formData.set("groupAccessDeniedMessage", "This group cannot access the server right now.");
|
||||
formData.set("vpnDeniedMessage", "VPN access requires a host-approved exception.");
|
||||
|
||||
expect(parseAdmissionMessages(formData)).toEqual({
|
||||
registrationMessage: "Register your account before joining.",
|
||||
groupAccessDeniedMessage: "This group cannot access the server right now.",
|
||||
vpnDeniedMessage: "VPN access requires a host-approved exception.",
|
||||
});
|
||||
});
|
||||
|
||||
it("selects the configured message for each admission denial reason", () => {
|
||||
expect(admissionDenialMessage("not_registered", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.registrationMessage);
|
||||
expect(admissionDenialMessage("group_access_disabled", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage);
|
||||
expect(admissionDenialMessage("anonymized_network_disallowed", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage);
|
||||
});
|
||||
|
||||
it("rejects missing, short, overlong, or control-character messages", () => {
|
||||
for (const invalid of ["short", "a".repeat(501), "Denied\nInjected"] as const) {
|
||||
const formData = new FormData();
|
||||
formData.set("registrationMessage", DEFAULT_ADMISSION_MESSAGES.registrationMessage);
|
||||
formData.set("groupAccessDeniedMessage", DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage);
|
||||
formData.set("vpnDeniedMessage", invalid);
|
||||
expect(parseAdmissionMessages(formData)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface AdmissionMessages {
|
||||
registrationMessage: string;
|
||||
groupAccessDeniedMessage: string;
|
||||
vpnDeniedMessage: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_ADMISSION_MESSAGES: AdmissionMessages = {
|
||||
registrationMessage: "Please register your Minecraft account before joining.",
|
||||
groupAccessDeniedMessage: "Your account group does not currently have server access. Contact a host if you believe this is a mistake.",
|
||||
vpnDeniedMessage: "VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.",
|
||||
} as const;
|
||||
|
||||
export function admissionDenialMessage(
|
||||
reason: "not_registered" | "group_access_disabled" | "anonymized_network_disallowed",
|
||||
messages: AdmissionMessages,
|
||||
) {
|
||||
if (reason === "not_registered") return messages.registrationMessage;
|
||||
if (reason === "group_access_disabled") return messages.groupAccessDeniedMessage;
|
||||
return messages.vpnDeniedMessage;
|
||||
}
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function messageValue(formData: FormData, name: string) {
|
||||
const value = String(formData.get(name) ?? "").trim();
|
||||
return value.length >= 10 && value.length <= 500 && !CONTROL_CHARACTERS.test(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export function parseAdmissionMessages(formData: FormData) {
|
||||
const registrationMessage = messageValue(formData, "registrationMessage");
|
||||
const groupAccessDeniedMessage = messageValue(formData, "groupAccessDeniedMessage");
|
||||
const vpnDeniedMessage = messageValue(formData, "vpnDeniedMessage");
|
||||
if (!registrationMessage || !groupAccessDeniedMessage || !vpnDeniedMessage) return null;
|
||||
return { registrationMessage, groupAccessDeniedMessage, vpnDeniedMessage };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adminGroupReturnPath, editableGroupName, effectiveGroupMemberCount, isEffectiveGroupMember, groupSlug, validateGroupDetails } from "./group-management";
|
||||
|
||||
describe("group management", () => {
|
||||
it("generates a collision-safe internal slug from the display name", () => {
|
||||
expect(groupSlug(" Trusted Öps Team! ", new Set(["trusted-ops-team", "trusted-ops-team-2"])))
|
||||
.toBe("trusted-ops-team-3");
|
||||
expect(groupSlug("🔥", new Set())).toBe("group");
|
||||
});
|
||||
|
||||
it("allows only local Users and group-detail return paths", () => {
|
||||
expect(adminGroupReturnPath("/admin/users?q=alex", "saved=group")).toBe("/admin/users?q=alex&saved=group");
|
||||
expect(adminGroupReturnPath("/admin/groups/11111111-1111-4111-8111-111111111111", "saved=group"))
|
||||
.toBe("/admin/groups/11111111-1111-4111-8111-111111111111?saved=group");
|
||||
expect(adminGroupReturnPath("https://evil.example/admin/users", "saved=group")).toBe("/admin/users?saved=group");
|
||||
expect(adminGroupReturnPath("/admin/settings", "error=invalid-group-assignment")).toBe("/admin/users?error=invalid-group-assignment");
|
||||
});
|
||||
|
||||
it("counts and filters explicit and default effective memberships", () => {
|
||||
const assignments = { one: "ops", two: "builders" };
|
||||
expect(effectiveGroupMemberCount(4, Object.values(assignments), { id: "everyone", isDefault: true })).toBe(2);
|
||||
expect(effectiveGroupMemberCount(4, Object.values(assignments), { id: "ops", isDefault: false })).toBe(1);
|
||||
expect(isEffectiveGroupMember("three", assignments, { id: "everyone", isDefault: true })).toBe(true);
|
||||
expect(isEffectiveGroupMember("one", assignments, { id: "ops", isDefault: false })).toBe(true);
|
||||
expect(isEffectiveGroupMember("two", assignments, { id: "ops", isDefault: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the protected default group name fixed", () => {
|
||||
expect(editableGroupName("everyone", true, "Renamed")).toBe("everyone");
|
||||
expect(editableGroupName("Ops", false, "Trusted hosts")).toBe("Trusted hosts");
|
||||
});
|
||||
|
||||
it("validates and normalizes editable group details", () => {
|
||||
expect(validateGroupDetails(" Trusted hosts ", " Can use managed VPNs. ")).toEqual({
|
||||
name: "Trusted hosts",
|
||||
description: "Can use managed VPNs.",
|
||||
});
|
||||
expect(validateGroupDetails("", "description")).toBeNull();
|
||||
expect(validateGroupDetails("bad\nname", "description")).toBeNull();
|
||||
expect(validateGroupDetails("Valid", "x".repeat(501))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const NAME_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
const TEXT_CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
||||
|
||||
export function validateGroupDetails(nameValue: unknown, descriptionValue: unknown) {
|
||||
const name = String(nameValue ?? "").trim();
|
||||
const description = String(descriptionValue ?? "").trim();
|
||||
if (
|
||||
name.length < 1 ||
|
||||
name.length > 50 ||
|
||||
NAME_CONTROL_CHARACTERS.test(name) ||
|
||||
description.length > 500 ||
|
||||
TEXT_CONTROL_CHARACTERS.test(description)
|
||||
) return null;
|
||||
return { name, description };
|
||||
}
|
||||
|
||||
export function adminGroupReturnPath(
|
||||
value: unknown,
|
||||
result: "saved=group" | "error=invalid-group-assignment",
|
||||
) {
|
||||
const requested = String(value ?? "");
|
||||
let pathname = "/admin/users";
|
||||
const parameters = new URLSearchParams();
|
||||
if (requested.startsWith("/")) {
|
||||
const url = new URL(requested, "http://internal");
|
||||
if (url.pathname === "/admin/users") {
|
||||
const search = url.searchParams.get("q")?.trim().slice(0, 100);
|
||||
if (search) parameters.set("q", search);
|
||||
} else if (/^\/admin\/groups\/[0-9a-f-]{36}$/i.test(url.pathname)) {
|
||||
pathname = url.pathname;
|
||||
}
|
||||
}
|
||||
const [key, resultValue] = result.split("=", 2) as ["saved" | "error", string];
|
||||
parameters.set(key, resultValue);
|
||||
return `${pathname}?${parameters.toString()}`;
|
||||
}
|
||||
|
||||
export function editableGroupName(currentName: string, isDefault: boolean, requestedName: string) {
|
||||
return isDefault ? currentName : requestedName;
|
||||
}
|
||||
|
||||
export function effectiveGroupMemberCount(
|
||||
totalUsers: number,
|
||||
assignedGroupIds: string[],
|
||||
group: { id: string; isDefault: boolean },
|
||||
) {
|
||||
return group.isDefault
|
||||
? Math.max(0, totalUsers - assignedGroupIds.length)
|
||||
: assignedGroupIds.filter((groupId) => groupId === group.id).length;
|
||||
}
|
||||
|
||||
export function isEffectiveGroupMember(
|
||||
userId: string,
|
||||
assignmentByUser: Record<string, string>,
|
||||
group: { id: string; isDefault: boolean },
|
||||
) {
|
||||
return group.isDefault ? !assignmentByUser[userId] : assignmentByUser[userId] === group.id;
|
||||
}
|
||||
|
||||
export function groupSlug(name: string, existingSlugs: Set<string>) {
|
||||
const normalized = name
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "group";
|
||||
const base = normalized.slice(0, 50).replace(/-+$/g, "") || "group";
|
||||
if (!existingSlugs.has(base)) return base;
|
||||
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
||||
const suffixText = `-${suffix}`;
|
||||
const candidate = `${base.slice(0, 50 - suffixText.length).replace(/-+$/g, "")}${suffixText}`;
|
||||
if (!existingSlugs.has(candidate)) return candidate;
|
||||
}
|
||||
throw new Error("Could not generate a unique group slug");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupMapLocations, parseUserLocation, projectWorldPoint } from "./user-location-map";
|
||||
import { groupMapLocations, parseUserLocation, parseUserNetwork, projectWorldPoint } from "./user-location-map";
|
||||
|
||||
describe("user location map", () => {
|
||||
it("extracts a valid approximate location from cached IP intelligence", () => {
|
||||
@@ -19,6 +19,29 @@ describe("user location map", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts enriched network fields from existing ProxyCheck cache entries", () => {
|
||||
expect(parseUserNetwork({
|
||||
network: { asn: "AS7922", provider: "Comcast Cable Communications, LLC" },
|
||||
rawResponse: {
|
||||
status: "ok",
|
||||
"203.0.113.10": { type: "Residential", proxy: "no" },
|
||||
},
|
||||
})).toEqual({
|
||||
asn: "AS7922",
|
||||
provider: "Comcast Cable Communications, LLC",
|
||||
connectionType: "Residential",
|
||||
proxy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers normalized network fields and preserves unavailable values", () => {
|
||||
expect(parseUserNetwork({
|
||||
network: { asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true },
|
||||
rawResponse: { "198.51.100.5": { type: "Residential", proxy: "no" } },
|
||||
})).toEqual({ asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true });
|
||||
expect(parseUserNetwork({ network: {} })).toEqual({ asn: null, provider: null, connectionType: null, proxy: null });
|
||||
});
|
||||
|
||||
it("rejects missing and out-of-range coordinates", () => {
|
||||
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
|
||||
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
|
||||
|
||||
@@ -6,6 +6,17 @@ function objectValue(value: unknown): UnknownMap | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function proxyValue(value: unknown) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string" && value.toLowerCase() === "yes") return true;
|
||||
if (typeof value === "string" && value.toLowerCase() === "no") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function coordinate(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
@@ -18,6 +29,28 @@ export interface ParsedUserLocation {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ParsedUserNetwork {
|
||||
asn: string | null;
|
||||
provider: string | null;
|
||||
connectionType: string | null;
|
||||
proxy: boolean | null;
|
||||
}
|
||||
|
||||
export function parseUserNetwork(value: unknown): ParsedUserNetwork {
|
||||
const intelligence = objectValue(value);
|
||||
const network = objectValue(intelligence?.network);
|
||||
const providerResponse = objectValue(intelligence?.rawResponse);
|
||||
const legacyDetails = Object.values(providerResponse ?? {})
|
||||
.map(objectValue)
|
||||
.find((details) => details && ("type" in details || "proxy" in details));
|
||||
return {
|
||||
asn: stringValue(network?.asn),
|
||||
provider: stringValue(network?.provider),
|
||||
connectionType: stringValue(network?.connectionType) ?? stringValue(legacyDetails?.type),
|
||||
proxy: proxyValue(network?.proxy) ?? proxyValue(legacyDetails?.proxy),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
|
||||
const intelligence = objectValue(value);
|
||||
const location = objectValue(intelligence?.location);
|
||||
|
||||
@@ -32,6 +32,7 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
|
||||
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
|
||||
* [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access.
|
||||
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks.
|
||||
* [US-019 — Manage groups efficiently](us-019-admin-group-management.md) - Administrators manage group identity, policies, membership, and creation through focused confirmed workflows.
|
||||
|
||||
# Tracking
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
* **Refine**: Replace admin group cards with a policy table, confirmed modal workflows, editable group details, and reusable effective-member management.
|
||||
* **Add**: Provide Users-page group assignment, effective-group VPN/proxy/Tor exceptions for game admission, and independent configurable denial messages.
|
||||
* **Fix**: Treat malformed ProxyCheck proxy signals as unknown and classify every authenticated Velocity login before identity resolution.
|
||||
* **Fix**: Replace the dashboard's pre-enrichment network label with enriched company, ASN, connection type, Proxy/VPN status, and risk fields.
|
||||
* **Fix**: Group collocated map users into count-badged markers with complete nickname tooltips and per-user interactive-map links.
|
||||
* **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections.
|
||||
* **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work.
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Enrich portal and game login IPs
|
||||
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
|
||||
tags: [security, network, audit, proxycheck]
|
||||
timestamp: 2026-08-01T22:04:17Z
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-007
|
||||
status: verified
|
||||
---
|
||||
@@ -19,7 +19,7 @@ As an operator, I want portal and registered game logins enriched with network c
|
||||
- [x] Provider failures are cached briefly and do not deny portal or registered game login.
|
||||
- [x] Private, loopback, reserved, documentation, and mapped-private addresses are never sent to ProxyCheck.
|
||||
- [x] Forwarded web IP headers are ignored unless trusted-proxy handling is explicitly enabled.
|
||||
- [x] Unknown game accounts do not trigger paid ProxyCheck lookups.
|
||||
- [x] Every authenticated Velocity login request uses the cached ProxyCheck path before identity resolution, preventing account-creation races from bypassing network policy.
|
||||
- [x] Login events and IP observations retain the available classification and approximate location.
|
||||
- [x] Users and administrators can see available location and classification in audit views.
|
||||
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Block account additions from anonymized networks
|
||||
description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications.
|
||||
tags: [security, vpn, proxy, minecraft]
|
||||
timestamp: 2026-08-02T00:12:32Z
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-008
|
||||
status: verified
|
||||
---
|
||||
@@ -22,6 +22,8 @@ As an operator, I want account additions blocked from anonymized networks, so th
|
||||
- [x] Blocked and classification-unavailable attempts create distinct audit events with safe intelligence details.
|
||||
- [x] Administrative account additions remain available as an authorized recovery path.
|
||||
- [x] Administrators see enriched risky-network observations collapsed to one latest summary per user.
|
||||
- [x] Game admission enforces confirmed VPN, proxy, and Tor classifications according to the user's effective-group exception policy.
|
||||
- [x] Account-addition blocking remains unchanged and independent from the game-admission exception.
|
||||
|
||||
# Implementation
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Enforce registration at the Velocity proxy
|
||||
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
|
||||
tags: [minecraft, velocity, whitelist, security]
|
||||
timestamp: 2026-08-02T00:12:32Z
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-009
|
||||
status: verified
|
||||
---
|
||||
@@ -27,6 +27,9 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
|
||||
- [x] The plugin records the real Velocity connection IP and supports Java Edition online mode only.
|
||||
- [x] After admission, Velocity reports `PostLoginEvent` as best-effort authenticated telemetry without disconnecting an admitted player when reporting fails.
|
||||
- [x] Confirmed-connection reports use fresh timestamps and database replay protection.
|
||||
- [x] Group-disabled and VPN/proxy/Tor-policy denials return distinct operator-configured messages.
|
||||
- [x] The default anonymized-network message directs the player to contact a host for an exception.
|
||||
- [x] API failures, malformed responses, and unauthorized requests retain fail-closed plugin fallback behavior.
|
||||
|
||||
# Implementation
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Operate settings and audit views
|
||||
description: Authorized administrators control server messaging and investigate recent platform events.
|
||||
tags: [admin, settings, audit, operations]
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-012
|
||||
status: verified
|
||||
---
|
||||
@@ -21,11 +21,16 @@ As an administrator, I want operational settings and audit visibility, so that I
|
||||
- [x] Event views show type, subject, IP, classification, and approximate location when available.
|
||||
- [x] Admin console access itself creates an audit event with the SSO identity.
|
||||
- [x] Settings, users, and events are linked from the shared admin navigation.
|
||||
- [x] Administrators can independently configure registration-required, group-access-disabled, and VPN/proxy/Tor-denied game messages.
|
||||
- [x] Every message is validated server-side and has a safe default.
|
||||
- [x] Admission-message changes are audited with the administrator identity without logging credentials.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/actions.ts`](../apps/web/src/app/admin/%28console%29/actions.ts)
|
||||
- [`apps/web/src/lib/admission-settings.ts`](../apps/web/src/lib/admission-settings.ts)
|
||||
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Manage users as an administrator
|
||||
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
||||
tags: [admin, users, minecraft, discord]
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
timestamp: 2026-08-02T15:03:59Z
|
||||
story_id: US-013
|
||||
status: verified
|
||||
---
|
||||
@@ -24,12 +24,22 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
||||
- [x] Administrators can set a new primary account and automatically update Discord.
|
||||
- [x] Every action rechecks role and account ownership and records the acting administrator.
|
||||
- [x] Discord failures do not falsely persist the requested name, primary, or removal change.
|
||||
- [x] Each row in the administrator user registry shows the user's effective group in an accessible dropdown.
|
||||
- [x] Selecting a group immediately applies the assignment; selecting `everyone` removes the explicit assignment.
|
||||
- [x] Group changes preserve the active user search and show accessible success or error feedback.
|
||||
- [x] Registry assignment changes revalidate administrator authorization, user existence, and group existence, and audit the previous and new effective groups.
|
||||
- [x] User rows and group-assignment controls are reusable between the Users registry and group-member details.
|
||||
- [x] Group details show only the group's effective members with identity, Discord, primary-account, account-count, status, and group columns.
|
||||
- [x] Changing a user's group requires modal confirmation and choosing `everyone` removes the explicit assignment.
|
||||
- [x] Moving a member to another group removes that user from the current effective-member list after confirmation.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/admin/(console)/users/page.tsx`](../apps/web/src/app/admin/%28console%29/users/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
||||
- [`apps/web/src/components/user-group-select.tsx`](../apps/web/src/components/user-group-select.tsx)
|
||||
- [`apps/web/src/components/admin-user-table.tsx`](../apps/web/src/components/admin-user-table.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Control Minecraft admission with groups
|
||||
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
||||
tags: [admin, groups, authorization, velocity, security]
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
timestamp: 2026-08-02T15:03:59Z
|
||||
story_id: US-017
|
||||
status: verified
|
||||
---
|
||||
@@ -24,12 +24,22 @@ As an administrator, I want to organize registered users into access groups, so
|
||||
- [x] The protected default group cannot be deleted.
|
||||
- [x] Group creation, membership, and access-policy changes are audited.
|
||||
- [x] Users and administrators can inspect the user's single effective group assignment.
|
||||
- [x] Every group has an independently configurable VPN/proxy/Tor exception policy.
|
||||
- [x] The protected `everyone` group and newly created groups disallow VPN, proxy, and Tor connections by default.
|
||||
- [x] Confirmed VPN, proxy, or Tor game connections are denied unless the user's single effective group allows anonymized networks.
|
||||
- [x] Clear and hosting classifications are not denied by this group policy, and unavailable intelligence does not independently deny a registered player.
|
||||
- [x] VPN policy changes are authorized server-side and audited.
|
||||
- [x] Group creation can explicitly initialize Minecraft and VPN/proxy/Tor policies while retaining deny-by-default controls.
|
||||
- [x] List and detail policy changes use the same confirmation workflow.
|
||||
- [x] Effective member counts include unassigned users who fall back to `everyone`.
|
||||
- [x] Group names and descriptions are validated and editable server-side.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql)
|
||||
- [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql)
|
||||
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Monitor community account activity
|
||||
description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks.
|
||||
tags: [admin, dashboard, metrics, security, maps, ssr]
|
||||
timestamp: 2026-08-02T01:23:21Z
|
||||
timestamp: 2026-08-02T12:05:27Z
|
||||
story_id: US-018
|
||||
status: verified
|
||||
---
|
||||
@@ -23,6 +23,10 @@ As an administrator, I want an operational dashboard of account and game activit
|
||||
- [x] Grouped-marker hover and keyboard focus list every managed Discord nickname at that location.
|
||||
- [x] Interactive grouped markers open a popup with links to every corresponding user record.
|
||||
- [x] Single-user markers retain their direct nickname tooltip and user-record link.
|
||||
- [x] The location list identifies the enriched network company and ASN when available.
|
||||
- [x] The location list shows ProxyCheck's connection type separately from its risk classification.
|
||||
- [x] The location list shows the provider's proxy/VPN signal as an explicit Yes or No value.
|
||||
- [x] Unknown is shown only for individual enriched fields that are unavailable, including existing cached responses.
|
||||
- [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels.
|
||||
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
|
||||
- [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days.
|
||||
@@ -44,7 +48,7 @@ As an administrator, I want an operational dashboard of account and game activit
|
||||
# Validation
|
||||
|
||||
- Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
|
||||
- Coordinate parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, text fallback, and attribution are covered by the user-world-map tests.
|
||||
- Coordinate parsing, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-world-map tests.
|
||||
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
||||
|
||||
# Related Stories
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Manage groups efficiently
|
||||
description: Administrators use concise policy tables, focused group details, and confirmed modal workflows to manage access groups.
|
||||
tags: [admin, groups, usability, authorization]
|
||||
timestamp: 2026-08-02T15:03:59Z
|
||||
story_id: US-019
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want a concise group policy table and focused group details, so that I can manage access without navigating cumbersome controls.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The main Groups page lists name, Minecraft access, VPN/proxy/Tor access, and effective member count with the default group first and remaining names ordered alphabetically.
|
||||
- [x] Policy controls show their current state and require confirmation in an accessible modal before mutation.
|
||||
- [x] Selecting a group name opens a detail page with its description, policies, and effective members.
|
||||
- [x] Add group opens an accessible modal asking for name, description, Minecraft access, and VPN/proxy/Tor access.
|
||||
- [x] New-group policies default to denied and can be enabled before creation.
|
||||
- [x] Administrators manage only the display name; an internal collision-safe slug is generated automatically.
|
||||
- [x] Administrators can edit group name and description; the protected `everyone` name remains fixed while its description remains editable.
|
||||
- [x] Non-default groups can be deleted only after modal confirmation, returning all affected users to `everyone`.
|
||||
- [x] Group identity, policy, creation, and deletion mutations commit atomically with their audit events.
|
||||
- [x] Modal controls support keyboard operation, focus management, cancellation, and clear pending state.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/groups/actions.ts`](../apps/web/src/app/admin/%28console%29/groups/actions.ts)
|
||||
- [`apps/web/src/components/admin-modal-form.tsx`](../apps/web/src/components/admin-modal-form.tsx)
|
||||
- [`apps/web/src/components/group-policy-control.tsx`](../apps/web/src/components/group-policy-control.tsx)
|
||||
- [`apps/web/src/lib/group-management.ts`](../apps/web/src/lib/group-management.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
Native-dialog interaction and pending-state behavior are covered by [`apps/web/src/components/admin-modal-form.test.tsx`](../apps/web/src/components/admin-modal-form.test.tsx). Slug, return-path, protected-name, and effective-membership behavior are covered by [`apps/web/src/lib/group-management.test.ts`](../apps/web/src/lib/group-management.test.ts). TypeScript, lint, accessibility review, Semgrep, production build, and OKF validation pass.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Manage users as an administrator](us-013-admin-user-management.md)
|
||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -12,6 +12,8 @@ Player account management, administrator navigation, dashboard metrics and chart
|
||||
- Darkened the accent color so accent text reaches at least 4.5:1 contrast on both canvas and panel backgrounds.
|
||||
- Preserved reduced-motion behavior and disabled decorative cursor animation when requested.
|
||||
- Added labels or accessible names to search, Minecraft username, settings, group, and event-filter controls.
|
||||
- Added reusable per-user group dropdowns that open labelled confirmation dialogs, restore the prior selection on cancellation, prevent dismissal while pending, and provide live progress and result feedback.
|
||||
- Group creation, policy, editing, and deletion use native modal dialogs with keyboard cancellation, focus management, descriptive confirmation text, and disabled pending controls.
|
||||
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
|
||||
- Added table captions, column scopes, and row scopes to administrator data tables.
|
||||
- Added `role=status` with polite announcements for successful nickname changes and `role=alert` with assertive announcements for errors.
|
||||
@@ -20,6 +22,7 @@ Player account management, administrator navigation, dashboard metrics and chart
|
||||
- Added an accessible title, description, date labels, per-point labels, and textual values to the daily-active-user chart.
|
||||
- Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations.
|
||||
- Collocated users share a visible count badge; hover and focus tooltips announce every nickname, while interactive grouped markers expose per-user popup links.
|
||||
- The semantic location table separates network company, connection type, Proxy/VPN status, and risk classification under explicit column headers.
|
||||
- Added keyboard-operable tabs for the server-rendered overview and opt-in interactive OpenStreetMap view.
|
||||
- Added explicit new-tab context to the external Discord invite link.
|
||||
- Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures.
|
||||
|
||||
@@ -16,7 +16,7 @@ The bot creates private login links in response to `/register` and `/account`. D
|
||||
|
||||
Velocity sends the authenticated Java UUID, current username, source IP, server ID, request ID, and occurrence time. The API matches UUID first. Username fallback is allowed only when the stored account has no UUID, after which UUID and canonical username are updated.
|
||||
|
||||
The admission decision is fail closed. Unknown players, invalid responses, expired requests, authentication failures, and unavailable API responses are denied with the configured registration message. After admission succeeds, `PostLoginEvent` reports a confirmed proxy connection through a fresh, authenticated, replay-protected request. Connection telemetry is best effort and never disconnects an already admitted player.
|
||||
The admission decision is fail closed. Unknown players, disabled effective groups, disallowed confirmed VPN/proxy/Tor connections, invalid responses, expired requests, authentication failures, and unavailable API responses are denied. Registration, group-access, and anonymized-network denials use independent operator-configured messages; transport and service failures retain the plugin's local fallback. After admission succeeds, `PostLoginEvent` reports a confirmed proxy connection through a fresh, authenticated, replay-protected request. Connection telemetry is best effort and never disconnects an already admitted player.
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
@@ -38,7 +38,7 @@ The admission decision is fail closed. Unknown players, invalid responses, expir
|
||||
|
||||
## IP intelligence
|
||||
|
||||
ProxyCheck.io supplies approximate city/region/country, coordinates, timezone, ASN, provider, risk, and VPN/proxy/Tor classification. Results are cached in PostgreSQL for 48 hours by default. Portal and game login events are enriched when data is available; lookup failures do not deny login. User Minecraft-account additions fail closed for unknown, VPN, proxy, or Tor classifications and record denied attempts. Hosting-provider blocking is optional through `BLOCK_HOSTING_IPS=true`. Private and reserved addresses are never sent to ProxyCheck.
|
||||
ProxyCheck.io supplies approximate city/region/country, coordinates, timezone, ASN, network company, connection type, proxy signal, risk, and VPN/proxy/Tor classification. Results are cached in PostgreSQL for 48 hours by default. Portal logins and every bearer-authenticated Velocity login are enriched through the cache before identity resolution; lookup failures do not independently deny a registered player. Confirmed VPN, proxy, and Tor game connections require an exception on the player's effective group. User Minecraft-account additions fail closed for unknown, VPN, proxy, or Tor classifications and record denied attempts. Hosting-provider blocking is optional through `BLOCK_HOSTING_IPS=true`. Private and reserved addresses are never sent to ProxyCheck.
|
||||
|
||||
## Event naming
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
||||
- Velocity admission and confirmed-connection requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
|
||||
- Velocity and its API fail closed.
|
||||
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
|
||||
- Group and membership mutations re-check the Keycloak administrator role server-side; destructive group deletion and its audit event commit atomically.
|
||||
- Confirmed VPN, proxy, and Tor game connections are denied unless that same effective group has an explicit exception; `everyone` and new groups default to no exception.
|
||||
- Group, VPN-policy, identity, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, policy changes, group edits, creation, deletion, and message settings commit atomically with their audit events.
|
||||
- Group identity creation/rename and membership assignment/deletion use compatible PostgreSQL advisory and row locks to prevent duplicate names, stale audit records, or membership/deletion races. The protected default name and deletion restriction are enforced server-side.
|
||||
- Every bearer-authenticated Velocity login uses cached IP intelligence before identity resolution, preventing account-creation races from bypassing network policy; malformed provider proxy signals classify as unknown.
|
||||
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
|
||||
- The administrator-only map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin.
|
||||
- Grouped-map popup labels and links are created with DOM `textContent` and server-rendered React escaping rather than interpolated HTML.
|
||||
@@ -33,7 +36,7 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
||||
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
|
||||
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
|
||||
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
|
||||
- Portal and game login events include approximate network location and VPN/proxy classification when available.
|
||||
- Portal and game login events include approximate network location and VPN/proxy classification when available. The administrator-only location list also exposes enriched network company, ASN, connection type, and the provider's proxy signal.
|
||||
- Structured Pino logging redacts credential fields, and secrets are excluded from logs and repository configuration.
|
||||
|
||||
## Outstanding production requirements
|
||||
|
||||
Generated
+693
-43
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,24 @@ export function resolveEffectiveGroup<T>(explicitGroup: T | null, defaultGroup:
|
||||
return explicitGroup ?? defaultGroup;
|
||||
}
|
||||
|
||||
export function isGameNetworkAllowed(
|
||||
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor",
|
||||
anonymizedNetworksAllowed: boolean,
|
||||
) {
|
||||
return anonymizedNetworksAllowed || !["vpn", "proxy", "tor"].includes(classification);
|
||||
}
|
||||
|
||||
export function gameAdmissionDenialReason(
|
||||
group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean } | null,
|
||||
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor",
|
||||
) {
|
||||
if (!group?.accessEnabled) return "group_access_disabled" as const;
|
||||
if (!isGameNetworkAllowed(classification, group.anonymizedNetworksAllowed)) {
|
||||
return "anonymized_network_disallowed" as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function verifyHashedToken(providedToken: string, expectedHash: string) {
|
||||
const provided = Buffer.from(hashToken(providedToken), "utf8");
|
||||
const expected = Buffer.from(expectedHash, "utf8");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveEffectiveGroup } from "../src/index";
|
||||
import { gameAdmissionDenialReason, isGameNetworkAllowed, resolveEffectiveGroup } from "../src/index";
|
||||
|
||||
describe("group-based admission", () => {
|
||||
const everyone = { name: "everyone", accessEnabled: false };
|
||||
@@ -17,4 +17,26 @@ describe("group-based admission", () => {
|
||||
const limited = { name: "limited", accessEnabled: false };
|
||||
expect(resolveEffectiveGroup(limited, enabledDefault)?.accessEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("denies confirmed anonymized game networks unless the effective group allows them", () => {
|
||||
for (const classification of ["vpn", "proxy", "tor"] as const) {
|
||||
expect(isGameNetworkAllowed(classification, false)).toBe(false);
|
||||
expect(isGameNetworkAllowed(classification, true)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not apply the group exception policy to clear, hosting, or unavailable intelligence", () => {
|
||||
for (const classification of ["clear", "hosting", "unknown"] as const) {
|
||||
expect(isGameNetworkAllowed(classification, false)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("prioritizes disabled group access before the network exception policy", () => {
|
||||
expect(gameAdmissionDenialReason({ accessEnabled: false, anonymizedNetworksAllowed: false }, "vpn"))
|
||||
.toBe("group_access_disabled");
|
||||
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn"))
|
||||
.toBe("anonymized_network_disallowed");
|
||||
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: true }, "vpn"))
|
||||
.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "app_settings" ADD COLUMN "group_access_denied_message" text DEFAULT 'Your account group does not currently have server access. Contact a host if you believe this is a mistake.' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "app_settings" ADD COLUMN "vpn_denied_message" text DEFAULT 'VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "groups" ADD COLUMN "anonymized_networks_allowed" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1785625186545,
|
||||
"tag": "0003_smiling_silver_samurai",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1785678753029,
|
||||
"tag": "0004_zippy_silver_centurion",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,12 +15,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"postgres": "^3.4.8"
|
||||
"postgres": "^3.4.8",
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export const groups = pgTable(
|
||||
slug: varchar("slug", { length: 50 }).notNull(),
|
||||
description: text("description"),
|
||||
accessEnabled: boolean("access_enabled").notNull().default(false),
|
||||
anonymizedNetworksAllowed: boolean("anonymized_networks_allowed").notNull().default(false),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
...timestamps(),
|
||||
},
|
||||
@@ -171,6 +172,12 @@ export const appSettings = pgTable("app_settings", {
|
||||
registrationMessage: text("registration_message")
|
||||
.notNull()
|
||||
.default("Please register your Minecraft account before joining."),
|
||||
groupAccessDeniedMessage: text("group_access_denied_message")
|
||||
.notNull()
|
||||
.default("Your account group does not currently have server access. Contact a host if you believe this is a mistake."),
|
||||
vpnDeniedMessage: text("vpn_denied_message")
|
||||
.notNull()
|
||||
.default("VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception."),
|
||||
...timestamps(),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface IpLocation {
|
||||
export interface IpNetwork {
|
||||
asn: string | null;
|
||||
provider: string | null;
|
||||
connectionType?: string | null;
|
||||
proxy?: boolean | null;
|
||||
}
|
||||
|
||||
export interface IpIntelligenceResult {
|
||||
@@ -49,7 +51,9 @@ function numberValue(value: unknown) {
|
||||
}
|
||||
|
||||
function proxyClassification(proxy: unknown, type: unknown): IpClassification {
|
||||
if (String(proxy).toLowerCase() !== "yes") return "clear";
|
||||
const normalizedProxy = String(proxy).toLowerCase();
|
||||
if (normalizedProxy === "no") return "clear";
|
||||
if (normalizedProxy !== "yes") return "unknown";
|
||||
const normalizedType = String(type ?? "").toLowerCase();
|
||||
if (normalizedType.includes("tor")) return "tor";
|
||||
if (normalizedType.includes("vpn")) return "vpn";
|
||||
@@ -112,6 +116,10 @@ export class ProxyCheckProvider implements IpIntelligenceProvider {
|
||||
network: {
|
||||
asn: stringValue(data.asn),
|
||||
provider: stringValue(data.provider) ?? stringValue(data.organisation),
|
||||
connectionType: stringValue(data.type),
|
||||
proxy: String(data.proxy).toLowerCase() === "yes"
|
||||
? true
|
||||
: String(data.proxy).toLowerCase() === "no" ? false : null,
|
||||
},
|
||||
rawResponse: root,
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("ProxyCheck.io intelligence", () => {
|
||||
longitude: -122.0775,
|
||||
timezone: "America/Los_Angeles",
|
||||
},
|
||||
network: { asn: "AS15169", provider: "Google LLC" },
|
||||
network: { asn: "AS15169", provider: "Google LLC", connectionType: "Business", proxy: false },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://proxycheck.io/v2/8.8.8.8"),
|
||||
@@ -50,6 +50,14 @@ describe("ProxyCheck.io intelligence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("treats missing or malformed proxy signals as unknown", async () => {
|
||||
for (const proxy of [undefined, null, "maybe"] as const) {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy, type: "Residential" }));
|
||||
await expect(new ProxyCheckProvider({ apiKey: "secret", request }).classify(ipAddress))
|
||||
.resolves.toMatchObject({ classification: "unknown", network: { proxy: null } });
|
||||
}
|
||||
});
|
||||
|
||||
it("maps VPN and Tor responses to explicit classifications", async () => {
|
||||
const vpnRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "VPN" }));
|
||||
const torRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "TOR" }));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Velocity admission plugin
|
||||
|
||||
The plugin checks every online-mode Java login against the account-manager API. It fails closed: unavailable, unauthorized, stale, replayed, malformed, and unknown requests are denied. After a player completes proxy login, the plugin sends best-effort `PostLoginEvent` telemetry used for confirmed-connection activity metrics; reporting failure is logged without disconnecting the player.
|
||||
The plugin checks every online-mode Java login against the account-manager API. It fails closed: unavailable, unauthorized, stale, replayed, malformed, unknown-account, group-disabled, and group-policy VPN/proxy/Tor requests are denied. After a player completes proxy login, the plugin sends best-effort `PostLoginEvent` telemetry used for confirmed-connection activity metrics; reporting failure is logged without disconnecting the player.
|
||||
|
||||
## Download or build
|
||||
|
||||
@@ -31,4 +31,4 @@ npm run plugin:create-credential --workspace @minecraft-account-manager/database
|
||||
|
||||
Copy the displayed token into the plugin's `api-token`. Configure the HTTPS account-manager URL and ensure `server-id` matches. Restrict access to the plugin configuration because it contains the bearer token, then restart Velocity.
|
||||
|
||||
The proxy must run in online mode. Unknown players and API failures receive the configured registration message.
|
||||
The proxy must run in online mode. Unknown players, disabled groups, and disallowed VPN/proxy/Tor connections receive their operator-configured API message. API failures receive the plugin's local registration fallback.
|
||||
|
||||
Reference in New Issue
Block a user