Compare commits

..
3 Commits
Author SHA1 Message Date
dmg ebc7c7df17 feat(dashboard): refine activity telemetry and maps
CI / validate (push) Successful in 5m24s
Release / release (push) Successful in 11m6s
2026-08-01 20:28:32 -04:00
dmg 9116107917 feat(dashboard): map latest user locations
CI / validate (push) Successful in 5m20s
Release / release (push) Successful in 10m40s
2026-08-01 19:41:03 -04:00
dmg b7c0083647 feat(portal): add SSR operations and exclusive groups
CI / validate (push) Successful in 5m20s
Release / release (push) Successful in 6m56s
2026-08-01 19:21:23 -04:00
64 changed files with 3238 additions and 378 deletions
+4 -2
View File
@@ -7,9 +7,11 @@ The `design/` directory is the OKF v0.1 product record for this repository. Use
Before changing behavior:
1. Read `design/index.md` and every story related to the requested behavior.
2. Update an existing story or create a new `design/us-NNN-short-name.md` story before implementation.
2. Draft updates to an existing story or create a new `design/us-NNN-short-name.md` story before implementation.
3. Define observable acceptance criteria using user or operator language.
4. Set story status to `proposed` or `in-progress` while the work is incomplete.
4. Present the relevant new or updated stories and acceptance criteria to the user for review, and wait for explicit confirmation before changing implementation code.
5. Incorporate requested story changes before proceeding.
6. Set story status to `proposed` or `in-progress` while the work is incomplete.
While implementing:
+3 -2
View File
@@ -76,11 +76,12 @@ 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, primary-account changes, and Discord nickname synchronization
- 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
- 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
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.
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.
+1 -1
View File
@@ -4,7 +4,7 @@ const contentSecurityPolicy = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"img-src 'self' data: https://tile.openstreetmap.org",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
+8 -1
View File
@@ -17,17 +17,24 @@
"@minecraft-account-manager/logging": "*",
"@minecraft-account-manager/minecraft": "*",
"@minecraft-account-manager/network": "*",
"d3-geo": "^3.1.1",
"drizzle-orm": "^0.45.1",
"leaflet": "^1.9.4",
"next": "^16.2.1",
"next-auth": "^4.24.13",
"react": "^19.2.3",
"react-dom": "^19.2.3"
"react-dom": "^19.2.3",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/d3-geo": "^3.1.1",
"@types/leaflet": "^1.9.22",
"@types/node": "^25.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1",
"tailwindcss": "^4.2.1",
+123 -123
View File
@@ -1,61 +1,75 @@
"use server";
import { formatDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
import { formatManagedDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
import { and, eq, isNull, ne } from "drizzle-orm";
import { redirect } from "next/navigation";
import { recordUserEvent } from "@/lib/audit";
import { db } from "@/lib/database";
import { hasDiscordNicknameConfirmation } from "@/lib/dashboard-change-confirmation";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { db } from "@/lib/database";
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
import { logger } from "@/lib/logger";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
type CurrentUser = Awaited<ReturnType<typeof requireCurrentUser>>;
type NicknameSyncResult = "updated" | "not-configured" | "failed";
async function synchronizeNickname(user: CurrentUser, nickname: string, operation: string): Promise<NicknameSyncResult> {
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!guildId || !botToken) return "not-configured";
try {
await updateGuildNickname({ guildId, discordUserId: user.discordUserId, nickname, botToken });
} catch (error) {
logger.error(
{ err: error, event: "account.discord_nickname_update_failed", operation },
"Failed to update the Discord guild nickname",
);
return "failed";
}
try {
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname,
operation,
});
} catch (error) {
logger.error(
{ err: error, event: "account.discord_nickname_audit_failed", operation },
"Discord nickname updated but its audit event could not be recorded",
);
}
return "updated";
}
function nicknameResultUrl(nickname: string, synchronization: NicknameSyncResult, additionalQuery?: string) {
const result = synchronization === "updated"
? `nicknameUpdated=${encodeURIComponent(nickname)}`
: `error=${synchronization === "not-configured" ? "nickname-not-configured" : "nickname-update-failed"}&nicknameExpected=${encodeURIComponent(nickname)}`;
return `/account?${additionalQuery ? `${additionalQuery}&` : ""}${result}`;
}
export async function updateFirstName(formData: FormData) {
const user = await requireCurrentUser();
const firstName = String(formData.get("firstName") ?? "").trim();
const confirmed = hasDiscordNicknameConfirmation(formData);
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
redirect("/account?error=invalid-name");
}
const [primary] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!primary) redirect("/account?error=nickname-not-configured");
if (!confirmed) redirect(`/account?pendingName=${encodeURIComponent(firstName)}`);
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!guildId || !botToken) redirect("/account?error=nickname-not-configured");
const nickname = formatDiscordNickname(firstName, primary.username);
try {
await db.transaction(async (tx) => {
await tx.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname,
botToken,
});
});
} catch (error) {
logger.error(
{ err: error, event: "account.first_name_update_failed" },
"Failed to update the user name and Discord nickname",
);
redirect(`/account?error=nickname-update-failed&pendingName=${encodeURIComponent(firstName)}`);
}
const [primary] = await db
.select({ username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)))
.limit(1);
const nickname = formatManagedDiscordNickname(firstName, primary?.username ?? null);
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
const synchronization = await synchronizeNickname(user, nickname, "update-first-name");
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname,
operation: "update-first-name",
});
redirect("/account?nicknameUpdated=1");
redirect(nicknameResultUrl(nickname, synchronization));
}
export async function addMinecraftAccount(formData: FormData) {
@@ -82,138 +96,124 @@ export async function addMinecraftAccount(formData: FormData) {
const profile = await lookupJavaProfile(requestedUsername);
if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`);
const [existing] = await db.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const [existing] = await db
.select({ id: minecraftAccounts.id })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
let failed = false;
const username = profile?.username ?? requestedUsername;
try {
await db.insert(minecraftAccounts).values({
userId: user.id,
minecraftUuid: profile?.uuid ?? null,
username: profile?.username ?? requestedUsername,
username,
validationStatus: profile ? "verified" : "user_confirmed",
lastVerifiedAt: profile ? new Date() : null,
isPrimary: !existing,
});
} catch {
failed = true;
redirect("/account?error=already-registered");
}
if (failed) redirect("/account?error=already-registered");
const nickname = !existing && user.firstName
? formatManagedDiscordNickname(user.firstName, username)
: null;
const synchronization = nickname
? await synchronizeNickname(user, nickname, "add-first-account")
: null;
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
username: profile?.username ?? requestedUsername,
username,
minecraftUuid: profile?.uuid ?? null,
validationStatus: profile ? "verified" : "user_confirmed",
});
redirect(existing ? "/account?added=1" : "/account?confirmNickname=1");
redirect(nickname && synchronization
? nicknameResultUrl(nickname, synchronization, "added=1")
: "/account?added=1");
}
export async function setPrimaryAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const confirmed = hasDiscordNicknameConfirmation(formData);
const [requestedAccount] = await db.select({ id: minecraftAccounts.id, username: minecraftAccounts.username }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const [requestedAccount] = await db
.select({ id: minecraftAccounts.id, username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!requestedAccount) redirect("/account?error=unknown-account");
if (!user.firstName) redirect("/account?error=nickname-not-configured");
if (!confirmed) redirect(`/account?pendingPrimary=${encodeURIComponent(requestedAccount.id)}`);
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!guildId || !botToken) redirect("/account?error=nickname-not-configured");
const nickname = formatDiscordNickname(user.firstName, requestedAccount.username);
const changed = await db.transaction(async (tx) => {
const [account] = await tx
.select({ id: minecraftAccounts.id })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.id, requestedAccount.id), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!account) return false;
let changed = false;
try {
changed = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, requestedAccount.id), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!account) return false;
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
);
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname,
botToken,
});
return true;
});
} catch (error) {
logger.error(
{ err: error, event: "account.primary_update_failed" },
"Failed to update the primary account and Discord nickname",
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
);
redirect(`/account?error=nickname-update-failed&pendingPrimary=${encodeURIComponent(requestedAccount.id)}`);
}
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
return true;
});
if (!changed) redirect("/account?error=unknown-account");
const nickname = formatManagedDiscordNickname(user.firstName, requestedAccount.username);
const synchronization = await synchronizeNickname(user, nickname, "set-primary-account");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", {
accountId: requestedAccount.id,
nickname,
});
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname,
operation: "set-primary-account",
});
redirect("/account?nicknameUpdated=1");
redirect(nicknameResultUrl(nickname, synchronization));
}
export async function removeMinecraftAccount(formData: FormData) {
const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? "");
const removed = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
if (!account) return false;
const result = await db.transaction(async (tx) => {
const [account] = await tx
.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!account) return null;
await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
let replacementUsername: string | null = null;
if (account.isPrimary) {
const [replacement] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const [replacement] = await tx
.select({ id: minecraftAccounts.id, username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (replacement) {
replacementUsername = replacement.username;
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id));
}
} else {
const [primary] = await tx
.select({ username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)))
.limit(1);
replacementUsername = primary?.username ?? null;
}
return true;
return { replacementUsername };
});
if (!removed) redirect("/account?error=unknown-account");
if (!result) redirect("/account?error=unknown-account");
const nickname = user.firstName
? formatManagedDiscordNickname(user.firstName, result.replacementUsername)
: null;
const synchronization = nickname
? await synchronizeNickname(user, nickname, "remove-account")
: null;
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId });
redirect("/account?removed=1&confirmNickname=1");
}
export async function confirmDashboardNickname() {
const user = await requireCurrentUser();
const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where(
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!user.firstName || !account || !guildId || !botToken) redirect("/account?error=nickname-not-configured");
try {
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname: formatDiscordNickname(user.firstName, account.username),
botToken,
});
} catch {
redirect("/account?error=nickname-update-failed&confirmNickname=1");
}
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname: formatDiscordNickname(user.firstName, account.username),
});
redirect("/account?nicknameUpdated=1");
redirect(nickname && synchronization
? nicknameResultUrl(nickname, synchronization, "removed=1")
: "/account?removed=1");
}
+30 -70
View File
@@ -1,4 +1,5 @@
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull, or } from "drizzle-orm";
import { logout } from "@/app/auth/actions";
@@ -7,9 +8,9 @@ import { requireCurrentUser } from "@/lib/auth/user-session";
import { groupAccessAddresses } from "@/lib/access-address-groups";
import { discordIdentity } from "@/lib/discord-identity";
import { intelligenceSummary } from "@/lib/event-ip-summary";
import { NicknameNotice } from "@/components/nickname-notice";
import {
addMinecraftAccount,
confirmDashboardNickname,
removeMinecraftAccount,
setPrimaryAccount,
updateFirstName,
@@ -30,6 +31,8 @@ const errorMessages: Record<string, string> = {
"ip-check-unavailable": "We could not verify your network, so account addition is temporarily blocked.",
};
export const dynamic = "force-dynamic";
export default async function AccountPage({
searchParams,
}: {
@@ -39,7 +42,7 @@ export default async function AccountPage({
const query = await searchParams;
const error = queryValue(query.error);
const unverified = queryValue(query.unverified);
const [accounts, observations, discord, accessGroups] = await Promise.all([
const [accounts, observations, discord, availableGroups] = await Promise.all([
db
.select()
.from(minecraftAccounts)
@@ -68,27 +71,15 @@ export default async function AccountPage({
]);
const addressGroups = groupAccessAddresses(observations);
const primary = accounts.find((account) => account.isPrimary);
const desiredNickname = user.firstName && primary
? formatDiscordNickname(user.firstName, primary.username)
const desiredNickname = user.firstName
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
: null;
const pendingName = queryValue(query.pendingName)?.trim();
const pendingPrimaryId = queryValue(query.pendingPrimary);
const pendingPrimary = accounts.find((account) => account.id === pendingPrimaryId);
const pendingChange = pendingName && pendingName.length <= 50 && primary
? {
kind: "name" as const,
label: `Change your name to ${pendingName}`,
nickname: formatDiscordNickname(pendingName, primary.username),
firstName: pendingName,
}
: pendingPrimary && user.firstName
? {
kind: "primary" as const,
label: `Make ${pendingPrimary.username} your primary account`,
nickname: formatDiscordNickname(user.firstName, pendingPrimary.username),
accountId: pendingPrimary.id,
}
: null;
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
const nicknameUpdated = queryValue(query.nicknameUpdated);
const nicknameExpected = queryValue(query.nicknameExpected);
const nicknameError = error === "nickname-update-failed" || error === "nickname-not-configured" ? error : null;
return (
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
@@ -101,46 +92,17 @@ export default async function AccountPage({
<form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form>
</header>
{error && (
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">
{error && !nicknameError && (
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">
{errorMessages[error] ?? "The requested change could not be completed."}
</p>
)}
{queryValue(query.nicknameUpdated) && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Profile and Discord nickname updated</p>}
{pendingChange && (
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
<div>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Review linked identity change</p>
<h2 className="mt-3 font-display text-2xl font-black uppercase">{pendingChange.label}</h2>
<p className="mt-3 text-sm leading-6 text-muted">Nothing changes until you confirm. This will also update your Discord nickname to:</p>
<p className="mt-2 font-display text-2xl font-black">{pendingChange.nickname}</p>
</div>
<div className="mt-6 flex flex-wrap items-center gap-4 sm:mt-0 sm:justify-end">
<form action={pendingChange.kind === "name" ? updateFirstName : setPrimaryAccount}>
{pendingChange.kind === "name"
? <input name="firstName" type="hidden" value={pendingChange.firstName} />
: <input name="accountId" type="hidden" value={pendingChange.accountId} />}
<input name="confirmDiscordNickname" type="hidden" value="yes" />
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Confirm both changes</button>
</form>
<a className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/account">Cancel</a>
</div>
</section>
)}
{queryValue(query.confirmNickname) && desiredNickname && (
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
<div>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Confirm Discord change</p>
<p className="mt-2 text-sm text-muted">Your community nickname will become</p>
<p className="mt-1 font-display text-2xl font-black">{desiredNickname}</p>
</div>
<form action={confirmDashboardNickname} className="mt-5 sm:mt-0">
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Confirm update</button>
</form>
</section>
)}
<NicknameNotice
error={nicknameError
? `${errorMessages[nicknameError]}${nicknameExpected ? ` Your intended nickname is ${nicknameExpected}.` : ""}`
: undefined}
nickname={nicknameUpdated}
/>
<div className="mt-12 grid gap-10 lg:grid-cols-[1.35fr_0.65fr]">
<div className="space-y-10">
@@ -162,8 +124,8 @@ export default async function AccountPage({
<p className="mt-2 break-all font-mono text-[10px] text-muted">{account.minecraftUuid ?? "UUID will be learned at game login"}</p>
</div>
<div className="flex gap-4">
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Review primary change</button></form>}
<form action={removeMinecraftAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4" type="submit">Remove</button></form>
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>}
<details className="relative"><summary className="cursor-pointer list-none font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4">Remove</summary><form action={removeMinecraftAccount} className="absolute right-0 z-10 mt-2 w-60 border border-accent bg-panel p-4 shadow-[5px_5px_0_var(--color-accent)]"><input name="accountId" type="hidden" value={account.id} /><p className="text-xs leading-5">Remove {account.username}? Your Discord nickname will update automatically.</p><button className="mt-3 bg-accent px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Confirm removal</button></form></details>
</div>
</article>
))}
@@ -180,7 +142,7 @@ export default async function AccountPage({
</form>
) : (
<form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
<input className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
<input aria-label="Minecraft username" className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Add account</button>
</form>
)}
@@ -224,16 +186,14 @@ export default async function AccountPage({
</dl>
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label>
<input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required />
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Current Discord nickname: <strong className="text-ink">{desiredNickname}</strong></p>}
<p className="mt-3 text-xs leading-5 text-muted">You will review the new Discord nickname before anything changes.</p>
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Review name change</button>
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Managed Discord nickname: <strong className="text-ink">{desiredNickname}</strong></p>}
<p className="mt-3 text-xs leading-5 text-muted">Saving automatically synchronizes this Discord guild nickname.</p>
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button>
</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>
<div className="mt-4 space-y-3">
{accessGroups.map((group) => <div className="flex items-center justify-between gap-3 border-t border-line pt-3 first:border-0 first:pt-0" key={group.id}><span className="font-mono text-xs font-bold">{group.name}{group.isDefault ? " · default" : ""}</span><span className={`px-2 py-1 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>)}
</div>
<p className="mt-4 text-xs leading-5 text-muted">Minecraft access is allowed when any listed group has access on.</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>
</section>
</aside>
</div>
+2 -2
View File
@@ -11,7 +11,7 @@ export async function saveDiscordSettings(formData: FormData) {
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
redirect("/admin?error=invalid-message");
redirect("/admin/settings?error=invalid-message");
}
await db
@@ -28,5 +28,5 @@ export async function saveDiscordSettings(formData: FormData) {
},
});
redirect("/admin?saved=1");
redirect("/admin/settings?saved=1");
}
@@ -0,0 +1,56 @@
import { events } from "@minecraft-account-manager/database";
import { eq } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/database";
import { eventIpSummary } from "@/lib/event-ip-summary";
export const dynamic = "force-dynamic";
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;
export default async function EventDetailPage({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = await params;
if (!UUID_PATTERN.test(eventId)) notFound();
const [event] = await db.select().from(events).where(eq(events.id, eventId)).limit(1);
if (!event) notFound();
const network = eventIpSummary(event.data);
return (
<main className="mx-auto max-w-5xl px-6 py-12">
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/events"> Event explorer</Link>
<header className="mt-7 border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvent detail</p>
<h1 className="mt-4 break-words font-display text-3xl font-black uppercase sm:text-5xl">{event.type}</h1>
<p className="mt-4 break-all font-mono text-xs text-muted">{event.id}</p>
</header>
<section className="mt-8 border border-line bg-panel p-6 shadow-[7px_7px_0_var(--color-shadow)]">
<h2 className="font-display text-2xl font-black uppercase">Envelope</h2>
<dl className="mt-5 grid gap-x-8 gap-y-5 sm:grid-cols-2">
<Detail label="Time"><time dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></Detail>
<Detail label="Spec version">{event.specVersion}</Detail>
<Detail label="Source">{event.source}</Detail>
<Detail label="Subject">{event.subject ?? "Not provided"}</Detail>
<Detail label="Content type">{event.dataContentType}</Detail>
<Detail label="Data schema">{event.dataSchema ?? "Not provided"}</Detail>
<Detail label="Actor user">{event.actorUserId ? <Link className="underline underline-offset-4" href={`/admin/users/${event.actorUserId}`}>{event.actorUserId}</Link> : "Not provided"}</Detail>
<Detail label="Correlation ID">{event.correlationId ?? "Not provided"}</Detail>
<Detail label="IP address">{event.ipAddress ?? "Not provided"}</Detail>
<Detail label="Network">{network.classification || network.location ? `${network.classification ?? "unknown"} · ${network.location ?? "location unavailable"}` : "Not provided"}</Detail>
<Detail label="Published">{event.publishedAt ? event.publishedAt.toISOString() : "Pending publication"}</Detail>
<Detail label="Recorded">{event.createdAt.toISOString()}</Detail>
</dl>
</section>
<section className="mt-10">
<h2 className="font-display text-2xl font-black uppercase">Event data</h2>
<pre className="mt-4 overflow-x-auto border border-line bg-ink p-5 font-mono text-xs leading-6 text-canvas" tabIndex={0}>{JSON.stringify(event.data, null, 2)}</pre>
</section>
</main>
);
}
function Detail({ label, children }: { label: string; children: React.ReactNode }) {
return <div><dt className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">{label}</dt><dd className="mt-1 break-all text-sm">{children}</dd></div>;
}
@@ -1,34 +1,92 @@
import { events } from "@minecraft-account-manager/database";
import { desc } from "drizzle-orm";
import { desc, inArray } from "drizzle-orm";
import Link from "next/link";
import { db } from "@/lib/database";
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
import { eventIpSummary } from "@/lib/event-ip-summary";
export default async function EventsPage() {
const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100);
export const dynamic = "force-dynamic";
function values(value: string | string[] | undefined) {
return Array.isArray(value) ? value : value ? [value] : [];
}
export default async function EventsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const query = await searchParams;
const typeRows = await db.select({ type: events.type }).from(events).groupBy(events.type).orderBy(events.type);
const availableTypes = typeRows.map((row) => row.type);
const category = normalizeEventCategory(values(query.category)[0]);
const selectedTypes = normalizeSelectedEventTypes(query.type, availableTypes);
const categoryTypes = category === "all"
? availableTypes
: availableTypes.filter((type) => eventCategory(type) === category);
const filteredTypes = selectedTypes.length
? selectedTypes.filter((type) => categoryTypes.includes(type))
: categoryTypes;
const recentEvents = filteredTypes.length
? await db.select().from(events).where(inArray(events.type, filteredTypes)).orderBy(desc(events.time)).limit(100)
: [];
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvents ledger</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase">Recent events</h1>
<div className="mt-10 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<h1 className="mt-4 font-display text-5xl font-black uppercase">Event explorer</h1>
<p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Filter the immutable audit ledger, then open an event to inspect its complete CloudEvents envelope and data.</p>
<form className="mt-8 border border-line bg-panel p-6" method="get">
<div className="grid gap-6 md:grid-cols-[0.45fr_1.55fr]">
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="event-category">
View
<select className="mt-3 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case" defaultValue={category} id="event-category" name="category">
{eventCategoryValues.map((value) => <option key={value} value={value}>{value === "all" ? "All activity" : value}</option>)}
</select>
</label>
<fieldset>
<legend className="font-mono text-xs font-bold uppercase tracking-wider">Event types</legend>
<details className="mt-3 border border-line bg-canvas p-4" open={selectedTypes.length > 0}>
<summary className="cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">{selectedTypes.length ? `${selectedTypes.length} selected` : "All types in this view"}</summary>
<div className="mt-4 grid max-h-64 gap-3 overflow-y-auto sm:grid-cols-2">
{availableTypes.map((type) => (
<label className="flex items-start gap-2 font-mono text-[10px] leading-4" key={type}>
<input className="mt-0.5 size-4 accent-[var(--accent)]" defaultChecked={selectedTypes.includes(type)} name="type" type="checkbox" value={type} />
<span className="break-all">{type}</span>
</label>
))}
</div>
</details>
</fieldset>
</div>
<div className="mt-5 flex flex-wrap gap-4">
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Apply filters</button>
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/events">Clear filters</Link>
</div>
</form>
<p className="mt-8 font-mono text-[10px] uppercase tracking-widest text-muted" role="status">Showing {recentEvents.length} most recent matching events</p>
<div className="mt-3 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">Filtered account manager events</caption>
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th><th className="p-4">Network</th></tr>
<tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Type</th><th className="p-4" scope="col">Subject</th><th className="p-4" scope="col">IP</th><th className="p-4" scope="col">Network</th></tr>
</thead>
<tbody className="divide-y divide-line text-xs">
{recentEvents.map((event) => {
const ip = eventIpSummary(event.data);
return (
<tr key={event.id}>
<td className="whitespace-nowrap p-4 font-mono text-muted">{event.time.toISOString()}</td>
<td className="p-4 font-mono font-bold">{event.type}</td>
<tr className="hover:bg-canvas/60" key={event.id}>
<td className="whitespace-nowrap p-4 font-mono text-muted"><time dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></td>
<th className="p-4 text-left font-mono font-bold" scope="row"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${event.id}`}>{event.type}</Link></th>
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
<td className="p-4"><div className="font-mono text-[10px] font-bold uppercase">{ip.classification ?? "—"}</div><div className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"}</div></td>
</tr>
);
})}
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events have been recorded.</td></tr>}
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events match these filters.</td></tr>}
</tbody>
</table>
</div>
@@ -3,15 +3,17 @@ import { asc, eq } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/database";
import { addGroupMember, removeGroupMember, setGroupAccess } from "../actions";
import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess } from "../actions";
const savedMessages: Record<string, string> = {
created: "Group created with access disabled.",
access: "Group access policy updated.",
"member-added": "User added to the group.",
"member-removed": "User removed from the group.",
"member-added": "User assigned to the group.",
"member-removed": "User returned to the default group.",
};
export const dynamic = "force-dynamic";
export default async function GroupPage({
params,
searchParams,
@@ -32,10 +34,16 @@ export default async function GroupPage({
discordGlobalName: users.discordGlobalName,
discordUserId: users.discordUserId,
}).from(users).orderBy(asc(users.discordUsername)),
db.select({ userId: userGroupMemberships.userId }).from(userGroupMemberships)
.where(eq(userGroupMemberships.groupId, group.id)),
db.select({
userId: userGroupMemberships.userId,
groupId: userGroupMemberships.groupId,
groupName: groups.name,
}).from(userGroupMemberships).innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)),
]);
const memberIds = new Set(memberships.map((membership) => membership.userId));
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;
return (
<main className="mx-auto max-w-6xl px-6 py-12">
@@ -55,30 +63,38 @@ export default async function GroupPage({
</form>
</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">{savedMessages[query.saved] ?? "Group updated."}</p>}
{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>}
<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">{group.isDefault ? allUsers.length : memberIds.size} members</span>
<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">Membership in <strong className="text-ink">everyone</strong> is automatic and cannot be removed.</p>}
{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 isMember = group.isDefault || memberIds.has(user.id);
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>
{group.isDefault ? (
<span className="font-mono text-[9px] font-bold uppercase text-muted">Automatic member</span>
{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={isMember ? removeGroupMember : addGroupMember}>
<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 underline underline-offset-4 ${isMember ? "text-accent" : "text-ink"}`} type="submit">{isMember ? "Remove from group" : "Add to group"}</button>
<button className="font-mono text-[9px] font-bold uppercase text-ink underline underline-offset-4" type="submit">Move to {group.name}</button>
</form>
)}
</article>
@@ -87,6 +103,23 @@ export default async function GroupPage({
{!allUsers.length && <p className="py-8 text-sm text-muted">No registered users yet.</p>}
</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>
)}
</main>
);
}
@@ -1,7 +1,10 @@
"use server";
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { randomUUID } from "node:crypto";
import { events, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { getClientIp } from "@minecraft-account-manager/network";
import { and, eq } 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";
@@ -74,10 +77,22 @@ export async function addGroupMember(formData: FormData) {
]);
if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership");
await db.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id }).onConflictDoNothing();
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.member-added", {
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"));
}
@@ -96,9 +111,81 @@ export async function removeGroupMember(formData: FormData) {
eq(userGroupMemberships.groupId, group.id),
eq(userGroupMemberships.userId, userId),
));
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.member-removed", {
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 deleteGroup(formData: FormData) {
const admin = await requireAdminSession();
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 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);
if (!group || group.isDefault) return null;
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: {
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");
}
@@ -10,9 +10,13 @@ const errors: Record<string, string> = {
"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.",
};
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
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([
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
@@ -23,20 +27,22 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
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">Every registered user belongs to <strong className="text-ink">everyone</strong>. A player can join only when at least one of their groups has access enabled.</p>
<p className="mt-5 max-w-2xl leading-7 text-muted">Each user has one effective group. Users without an explicit assignment fall back to <strong className="text-ink">everyone</strong>; Minecraft admission follows only that groups access setting.</p>
</header>
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">{errors[query.error] ?? "The group operation failed."}</p>}
{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>}
<section className="mt-10 grid gap-5 md:grid-cols-2">
{allGroups.map((group) => {
const memberCount = group.isDefault ? registeredUsers.length : membershipCounts.get(group.id) ?? 0;
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">
+6 -3
View File
@@ -9,6 +9,8 @@ import { AdminSignOutButton } from "@/components/admin-sign-out-button";
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
import { db } from "@/lib/database";
export const dynamic = "force-dynamic";
export default async function AdminConsoleLayout({ children }: { children: ReactNode }) {
const session = await getServerSession(adminAuthOptions);
if (!session) redirect("/admin/login");
@@ -29,10 +31,11 @@ export default async function AdminConsoleLayout({ children }: { children: React
return (
<div className="min-h-screen bg-canvas text-ink">
<header className="border-b border-line bg-panel">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-5 px-6 py-5">
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">SoMC Portal / Ops</Link>
<nav className="ml-auto mr-8 flex gap-5 font-mono text-[10px] font-bold uppercase tracking-widest">
<Link className="hover:text-accent" href="/admin">Settings</Link>
<nav aria-label="Administrator" className="ml-auto flex flex-wrap gap-5 font-mono text-[10px] font-bold uppercase tracking-widest">
<Link className="hover:text-accent" href="/admin">Dashboard</Link>
<Link className="hover:text-accent" href="/admin/settings">Settings</Link>
<Link className="hover:text-accent" href="/admin/users">Users</Link>
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
<Link className="hover:text-accent" href="/admin/events">Events</Link>
+200 -40
View File
@@ -1,50 +1,210 @@
import { eq } from "drizzle-orm";
import { appSettings } from "@minecraft-account-manager/database";
import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, isNull, sql } from "drizzle-orm";
import Link from "next/link";
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
import { db } from "@/lib/database";
import { saveDiscordSettings } from "./actions";
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
import { parseUserLocation } from "@/lib/user-location-map";
export default async function AdminPage({
searchParams,
}: {
searchParams: Promise<{ saved?: string; error?: string }>;
}) {
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 guildId = process.env.DISCORD_GUILD_ID?.trim();
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
export const dynamic = "force-dynamic";
export default async function AdminDashboardPage() {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1_000);
const fourteenDaysAgo = new Date(now.getTime() - 13 * 24 * 60 * 60 * 1_000);
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
const [dailyActiveRows, [totals], [monthlyActive], [monthlyAccounts], locationRows, riskyLatestRows, riskySummaryRows, [recentDenials]] = await Promise.all([
db
.select({
day: sql<string>`to_char(date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
count: countDistinct(ipObservations.userId),
})
.from(ipObservations)
.where(and(gte(ipObservations.observedAt, fourteenDaysAgo), isNotNull(ipObservations.userId)))
.groupBy(sql`date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC')`)
.orderBy(sql`date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC')`),
db.select({ users: count(users.id) }).from(users),
db.select({
users: countDistinct(ipObservations.userId),
}).from(ipObservations).where(and(
gte(ipObservations.observedAt, thirtyDaysAgo),
isNotNull(ipObservations.userId),
)),
db.select({ accounts: countDistinct(events.subject) }).from(events).where(and(
eq(events.type, "games.minecraft.account-manager.game.player.connected"),
gte(events.time, thirtyDaysAgo),
)),
db
.selectDistinctOn([ipObservations.userId], {
userId: ipObservations.userId,
name: users.firstName,
discordUsername: users.discordUsername,
primaryUsername: minecraftAccounts.username,
classification: ipObservations.classification,
source: ipObservations.source,
observedAt: ipObservations.observedAt,
intelligence: ipIntelligence.rawResponse,
})
.from(ipObservations)
.innerJoin(users, eq(users.id, ipObservations.userId))
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.leftJoin(minecraftAccounts, and(
eq(minecraftAccounts.userId, users.id),
eq(minecraftAccounts.isPrimary, true),
isNull(minecraftAccounts.deletedAt),
))
.where(and(
isNotNull(ipObservations.userId),
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
))
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
db
.selectDistinctOn([ipObservations.userId], {
id: ipObservations.id,
classification: ipIntelligence.classification,
observedAt: ipObservations.observedAt,
source: ipObservations.source,
userId: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
accountUsername: minecraftAccounts.username,
})
.from(ipObservations)
.innerJoin(users, eq(users.id, ipObservations.userId))
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(and(
isNotNull(ipObservations.userId),
gte(ipObservations.observedAt, thirtyDaysAgo),
inArray(ipIntelligence.classification, ["vpn", "proxy", "tor"]),
))
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
db
.select({
userId: ipObservations.userId,
count: count(),
classifications: sql<string[]>`array_agg(distinct ${ipIntelligence.classification}::text order by ${ipIntelligence.classification}::text)`,
sources: sql<string[]>`array_agg(distinct ${ipObservations.source}::text order by ${ipObservations.source}::text)`,
})
.from(ipObservations)
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(and(
isNotNull(ipObservations.userId),
gte(ipObservations.observedAt, thirtyDaysAgo),
inArray(ipIntelligence.classification, ["vpn", "proxy", "tor"]),
))
.groupBy(ipObservations.userId),
db.select({ count: count() }).from(events).where(and(
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
gte(events.time, oneDayAgo),
)),
]);
const dailyActive = fillDailySeries(dailyActiveRows as DailyCount[], now, 14);
const riskyActivity = mergeRiskActivity(riskyLatestRows, riskySummaryRows).slice(0, 10);
const locations = locationRows.flatMap((row): UserMapLocation[] => {
const parsed = parseUserLocation(row.intelligence);
if (!parsed || !row.userId) return [];
return [{
userId: row.userId,
name: row.name ?? row.discordUsername,
discordUsername: row.discordUsername,
nickname: formatManagedDiscordNickname(row.name ?? row.discordUsername, row.primaryUsername ?? null),
latitude: parsed.latitude,
longitude: parsed.longitude,
location: parsed.label,
classification: row.classification,
source: row.source,
observedAt: row.observedAt,
}];
});
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]">
<section>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
<dl className="mt-7 space-y-4 font-mono text-[10px] uppercase tracking-wider text-muted">
<div><dt className="font-bold text-ink">Guild ID</dt><dd className="mt-1 break-all normal-case">{guildId ?? "Missing"}</dd></div>
<div><dt className="font-bold text-ink">Invite URL</dt><dd className="mt-1 break-all normal-case">{inviteUrl ? <a className="text-ink underline decoration-accent underline-offset-4" href={inviteUrl} rel="noreferrer" target="_blank">{inviteUrl}</a> : "Missing"}</dd></div>
</dl>
<header className="border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Operations overview</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Dashboard</h1>
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
</header>
<UserWorldMap locations={locations} unavailableCount={Math.max(0, (totals?.users ?? 0) - locations.length)} />
<section aria-label="Key metrics" className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="Registered users" value={totals?.users ?? 0} detail="All time" />
<Metric label="Monthly active users" value={monthlyActive?.users ?? 0} detail="Distinct users · 30 days" />
<Metric label="Active Minecraft accounts" value={monthlyAccounts?.accounts ?? 0} detail="Confirmed connections · 30 days" />
<Metric label="Login denials" value={recentDenials?.count ?? 0} detail="Past 24 hours" accent />
</section>
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
<DailyActiveChart data={dailyActive} />
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<div className="flex items-start justify-between gap-4">
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Network review</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Recent risky network activity</h2><p className="mt-2 text-xs text-muted">Collapsed per user across VPN, proxy, and Tor observations from the past 30 days.</p></div>
<Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/events?category=security">All security events</Link>
</div>
<div className="mt-5 divide-y divide-line">
{riskyActivity.map((activity) => (
<article className="py-4" key={activity.id}>
<div className="flex items-start justify-between gap-3">
<div>
{activity.userId ? <Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/users/${activity.userId}`}>{activity.firstName ?? activity.discordUsername ?? "Unknown user"}</Link> : <span className="font-mono text-xs font-bold">Unknown user</span>}
<p className="mt-1 text-xs text-muted">{activity.accountUsername ?? "No Minecraft account"} · {activity.sources.join(" + ")} · {activity.count} {activity.count === 1 ? "observation" : "observations"}</p>
</div>
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classifications.join(" + ")}</span>
</div>
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
</article>
))}
{!riskyActivity.length && <p className="py-6 text-sm text-muted">No recent VPN, proxy, or Tor observations.</p>}
</div>
</section>
<form action={saveDiscordSettings} 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">Settings saved</p>}
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm">Check the highlighted configuration values 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
/>
<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 Metric({ label, value, detail, accent = false }: { label: string; value: number; detail: string; accent?: boolean }) {
return (
<article className={`border p-5 ${accent ? "border-accent bg-ink text-canvas" : "border-line bg-panel"}`}>
<p className={`font-mono text-[9px] font-bold uppercase tracking-widest ${accent ? "text-signal" : "text-muted"}`}>{label}</p>
<p className="mt-3 font-display text-5xl font-black">{value}</p>
<p className={`mt-2 text-xs ${accent ? "text-canvas" : "text-muted"}`}>{detail}</p>
</article>
);
}
function DailyActiveChart({ data }: { data: DailyCount[] }) {
const width = 720;
const height = 260;
const padding = 32;
const maximum = Math.max(1, ...data.map((entry) => entry.count));
const points = data.map((entry, index) => {
const x = padding + index * ((width - padding * 2) / Math.max(1, data.length - 1));
const y = height - padding - (entry.count / maximum) * (height - padding * 2);
return `${x},${y}`;
}).join(" ");
return (
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Activity signal</p>
<h2 className="mt-2 font-display text-2xl font-black uppercase">Daily active users</h2>
<svg aria-labelledby="daily-active-chart-title daily-active-chart-description" className="mt-6 h-auto w-full" role="img" viewBox={`0 0 ${width} ${height}`}>
<title id="daily-active-chart-title">Daily active users over the last 14 days</title>
<desc id="daily-active-chart-description">Distinct daily users range from zero to {maximum}. Date-labelled values follow the chart.</desc>
<line stroke="var(--line)" strokeWidth="1" x1={padding} x2={width - padding} y1={height - padding} y2={height - padding} />
<polyline fill="none" points={points} stroke="var(--accent)" strokeLinecap="square" strokeLinejoin="miter" strokeWidth="4" />
{data.map((entry, index) => {
const [x, y] = points.split(" ")[index]!.split(",");
return <circle cx={x} cy={y} fill="var(--panel)" key={entry.day} r="5" stroke="var(--ink)" strokeWidth="3"><title>{entry.day}: {entry.count} active users</title></circle>;
})}
</svg>
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center sm:grid-cols-[repeat(14,minmax(0,1fr))]">
{data.map((entry) => <div key={entry.day}><dt className="font-mono text-[8px] text-muted"><time dateTime={entry.day}>{entry.day.slice(5)}</time></dt><dd className="mt-1 font-mono text-xs font-bold">{entry.count}</dd></div>)}
</dl>
</section>
);
}
@@ -0,0 +1,51 @@
import { appSettings } from "@minecraft-account-manager/database";
import { eq } from "drizzle-orm";
import { db } from "@/lib/database";
import { saveDiscordSettings } from "../actions";
export const dynamic = "force-dynamic";
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ saved?: string; error?: string }>;
}) {
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 guildId = process.env.DISCORD_GUILD_ID?.trim();
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]">
<section>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
<dl className="mt-7 space-y-4 font-mono text-[10px] uppercase tracking-wider text-muted">
<div><dt className="font-bold text-ink">Guild ID</dt><dd className="mt-1 break-all normal-case">{guildId ?? "Missing"}</dd></div>
<div><dt className="font-bold text-ink">Invite URL</dt><dd className="mt-1 break-all normal-case">{inviteUrl ? <a className="text-ink underline decoration-accent underline-offset-4" href={inviteUrl} rel="noreferrer" target="_blank">{inviteUrl}<span className="sr-only"> (opens in a new tab)</span></a> : "Missing"}</dd></div>
</dl>
</section>
<form action={saveDiscordSettings} 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
/>
<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>
);
}
@@ -1,11 +1,13 @@
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull, or } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { groupAccessAddresses } from "@/lib/access-address-groups";
import { db } from "@/lib/database";
import { discordIdentity } from "@/lib/discord-identity";
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
import { eventIpSummary } from "@/lib/event-ip-summary";
import {
addUserMinecraftAccount,
@@ -33,30 +35,50 @@ const savedMessages: Record<string, string> = {
nickname: "Discord nickname synchronized.",
};
export const dynamic = "force-dynamic";
function queryValues(value: string | string[] | undefined) {
return Array.isArray(value) ? value : value ? [value] : [];
}
export default async function AdminUserPage({
params,
searchParams,
}: {
params: Promise<{ userId: string }>;
searchParams: Promise<{ error?: string; saved?: string; unverified?: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { userId } = await params;
const query = await searchParams;
const error = queryValues(query.error)[0];
const saved = queryValues(query.saved)[0];
const unverified = queryValues(query.unverified)[0];
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) notFound();
const [accounts, recentEvents, observations, discord, accessGroups] = await Promise.all([
const userEventCondition = or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id));
const eventTypeRows = await db.select({ type: events.type }).from(events).where(userEventCondition).groupBy(events.type).orderBy(events.type);
const availableEventTypes = eventTypeRows.map((row) => row.type);
const selectedCategory = normalizeEventCategory(queryValues(query.eventCategory)[0]);
const selectedEventTypes = normalizeSelectedEventTypes(query.eventType, availableEventTypes);
const categoryTypes = selectedCategory === "all"
? availableEventTypes
: availableEventTypes.filter((type) => eventCategory(type) === selectedCategory);
const filteredEventTypes = selectedEventTypes.length
? selectedEventTypes.filter((type) => categoryTypes.includes(type))
: categoryTypes;
const recentEventsQuery = filteredEventTypes.length
? db.select().from(events).where(and(userEventCondition, inArray(events.type, filteredEventTypes))).orderBy(desc(events.time)).limit(30)
: Promise.resolve([]);
const [accounts, recentEvents, observations, discord, availableGroups] = await Promise.all([
db
.select()
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
db
.select()
.from(events)
.where(or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id)))
.orderBy(desc(events.time))
.limit(30),
recentEventsQuery,
db
.select()
.from(ipObservations)
@@ -70,6 +92,9 @@ export default async function AdminUserPage({
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
.orderBy(desc(groups.isDefault), groups.name),
]);
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
const addressGroups = groupAccessAddresses(
observations.map((observation) => ({ ...observation, intelligence: null })),
);
@@ -99,8 +124,8 @@ export default async function AdminUserPage({
</div>
</header>
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">{errorMessages[query.error] ?? "The requested operation failed."}</p>}
{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">{savedMessages[query.saved] ?? "Changes saved."}</p>}
{error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[error] ?? "The requested operation failed."}</p>}
{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[saved] ?? "Changes saved."}</p>}
<div className="mt-10 grid gap-10 lg:grid-cols-[1.3fr_0.7fr]">
<div className="space-y-10">
@@ -136,10 +161,10 @@ export default async function AdminUserPage({
{!accounts.length && <p className="py-7 text-sm text-muted">No active Minecraft accounts.</p>}
</div>
{query.unverified ? (
{unverified ? (
<form action={addUserMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
<input name="userId" type="hidden" value={user.id} /><input name="username" type="hidden" value={query.unverified} /><input name="forceUnverified" type="hidden" value="yes" />
<h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {query.unverified}</h3>
<input name="userId" type="hidden" value={user.id} /><input name="username" type="hidden" value={unverified} /><input name="forceUnverified" type="hidden" value="yes" />
<h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {unverified}</h3>
<p className="mt-2 text-sm leading-6 text-muted">Only override this when you have independently confirmed the spelling.</p>
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas" type="submit">Add unverified account</button>
<a className="ml-5 font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Cancel</a>
@@ -147,7 +172,7 @@ export default async function AdminUserPage({
) : (
<form action={addUserMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
<input name="userId" type="hidden" value={user.id} />
<input className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
<input aria-label="Minecraft username" className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Verify and add</button>
</form>
)}
@@ -155,11 +180,16 @@ export default async function AdminUserPage({
<section>
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Audit trail</p>
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Recent events</h2>
<h2 className="mt-2 font-display text-3xl font-black uppercase">Recent events</h2>
<form className="mt-4 grid gap-4 border-y border-line bg-panel p-4 sm:grid-cols-2" method="get">
<label className="font-mono text-[10px] font-bold uppercase" htmlFor="user-event-category">View<select className="mt-2 block w-full border border-line bg-canvas p-2 font-sans text-sm font-normal normal-case" defaultValue={selectedCategory} id="user-event-category" name="eventCategory">{eventCategoryValues.map((value) => <option key={value} value={value}>{value === "all" ? "All activity" : value}</option>)}</select></label>
<fieldset><legend className="font-mono text-[10px] font-bold uppercase">Types</legend><details className="mt-2 border border-line bg-canvas p-2"><summary className="cursor-pointer font-mono text-[9px] underline">{selectedEventTypes.length ? `${selectedEventTypes.length} selected` : "All types"}</summary><div className="mt-3 max-h-44 space-y-2 overflow-y-auto">{availableEventTypes.map((type) => <label className="flex items-start gap-2 font-mono text-[9px]" key={type}><input className="mt-0.5 size-4" defaultChecked={selectedEventTypes.includes(type)} name="eventType" type="checkbox" value={type} /><span className="break-all">{type}</span></label>)}</div></details></fieldset>
<div className="flex gap-4 sm:col-span-2"><button className="bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Filter events</button><Link className="self-center font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Clear</Link></div>
</form>
<div className="divide-y divide-line">
{recentEvents.map((event) => {
const ip = eventIpSummary(event.data);
return <div className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><p className="font-mono text-xs font-bold">{event.type}</p>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><span className="font-mono text-[9px] text-muted">{event.time.toISOString()}</span></div>;
return <article className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><h3 className="font-mono text-xs font-bold"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${event.id}`}>{event.type}</Link></h3>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><time className="font-mono text-[9px] text-muted" dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></article>;
})}
{!recentEvents.length && <p className="py-6 text-sm text-muted">No events recorded for this user.</p>}
</div>
@@ -178,9 +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>
<div className="mt-4 space-y-3">
{accessGroups.map((group) => <div className="flex items-center justify-between gap-3" key={group.id}><Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/groups/${group.id}`}>{group.name}{group.isDefault ? " · default" : ""}</Link><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "On" : "Off"}</span></div>)}
</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>}
</section>
<section className="border border-line bg-panel p-6">
@@ -3,6 +3,8 @@ import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
import Link from "next/link";
import { db } from "@/lib/database";
export const dynamic = "force-dynamic";
export default async function AdminUsersPage({
searchParams,
}: {
@@ -66,6 +68,7 @@ export default async function AdminUsersPage({
</div>
<form className="flex w-full max-w-md gap-2" method="get">
<input
aria-label="Search users"
className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-xs outline-none focus:border-accent"
defaultValue={search}
name="q"
@@ -76,17 +79,18 @@ 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">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">The requested user could not be found.</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">User</th><th className="p-4">Discord</th><th className="p-4">Primary</th><th className="p-4">Accounts</th><th className="p-4">Status</th></tr>
<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}>
<td className="p-4"><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></td>
<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>
+14 -8
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { enabledAccessGroup, isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
import {
appSettings,
@@ -11,7 +11,7 @@ import {
pluginRequests,
userGroupMemberships,
} from "@minecraft-account-manager/database";
import { and, eq, isNull, lt, or, sql } from "drizzle-orm";
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/database";
import { isUniqueConstraintViolation } from "@/lib/database-errors";
@@ -212,14 +212,20 @@ async function handleVelocityAccess(request: Request) {
return { allowed: false as const, message: denialMessage };
}
const assignedGroups = await tx
const [explicitGroup] = await tx
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
.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 })
.from(groups)
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, account.userId)));
const enabledGroup = enabledAccessGroup(assignedGroups);
.where(eq(groups.isDefault, true))
.limit(1);
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
if (!enabledGroup) {
if (!effectiveGroup?.accessEnabled) {
await tx.insert(events).values({
id: randomUUID(),
source: `/velocity/${input.serverId}`,
@@ -297,7 +303,7 @@ async function handleVelocityAccess(request: Request) {
previousUsername: account.username === input.username ? null : account.username,
uuidBackfilled: account.minecraftUuid === null,
ipIntelligence: auditIpData,
accessGroup: enabledGroup.name,
accessGroup: effectiveGroup.name,
},
ipAddress: input.ipAddress,
correlationId: input.requestId,
@@ -0,0 +1,134 @@
import { hashToken } from "@minecraft-account-manager/auth";
import { beforeEach, describe, expect, it, vi } from "vitest";
const databaseState = vi.hoisted(() => ({
account: { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", userId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" } as { id: string; userId: string } | null,
inserts: [] as Record<string, unknown>[],
credentialHash: "" as string | null,
replay: false,
}));
vi.mock("@/lib/database", () => ({
db: {
select: () => ({
from: () => ({
where: () => ({
limit: async () => databaseState.credentialHash ? [{ secretHash: databaseState.credentialHash }] : [],
}),
}),
}),
transaction: async (callback: (tx: unknown) => Promise<unknown>) => callback({
delete: () => ({ where: async () => undefined }),
insert: () => ({
values: async (value: Record<string, unknown>) => {
if (databaseState.replay && "requestId" in value) {
throw { code: "23505", constraint_name: "plugin_requests_pkey" };
}
databaseState.inserts.push(value);
},
}),
select: () => ({
from: () => ({
where: () => ({
limit: async () => databaseState.account ? [databaseState.account] : [],
}),
}),
}),
}),
},
}));
import { GET, POST } from "./route";
function validRequest(overrides: Record<string, unknown> = {}) {
return new Request("http://localhost/api/velocity/connection", {
method: "POST",
headers: { authorization: "Bearer valid-token", "content-type": "application/json" },
body: JSON.stringify({
requestId: "8dd9dbdc-020a-4077-983c-77747522de8f",
serverId: "velocity-main",
minecraftUuid: "069a79f444e94726a5befca90e38aaf5",
username: "Notch",
occurredAt: new Date().toISOString(),
...overrides,
}),
});
}
describe("Velocity connection reporting endpoint", () => {
beforeEach(() => {
databaseState.account = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", userId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" };
databaseState.inserts = [];
databaseState.credentialHash = hashToken("valid-token");
databaseState.replay = false;
});
it("rejects methods other than POST with Problem Details", async () => {
const response = GET(new Request("http://localhost/api/velocity/connection"));
expect(response.status).toBe(405);
expect(response.headers.get("content-type")).toContain("application/problem+json");
expect(response.headers.get("allow")).toBe("POST");
});
it("requires a server credential", async () => {
const response = await POST(new Request("http://localhost/api/velocity/connection", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}));
expect(response.status).toBe(401);
});
it("validates the report before database access", async () => {
const response = await POST(new Request("http://localhost/api/velocity/connection", {
method: "POST",
headers: { authorization: "Bearer test", "content-type": "application/json" },
body: JSON.stringify({ username: "bad name" }),
}));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ type: "urn:error:invalid-velocity-connection-request", status: 400 });
});
it("rejects invalid or revoked server credentials", async () => {
databaseState.credentialHash = null;
const response = await POST(validRequest());
expect(response.status).toBe(401);
expect(databaseState.inserts).toHaveLength(0);
});
it("rejects stale reports before recording them", async () => {
const response = await POST(validRequest({ occurredAt: "2026-01-01T00:00:00.000Z" }));
expect(response.status).toBe(401);
expect(databaseState.inserts).toHaveLength(0);
});
it("authenticates and atomically records a confirmed account connection", async () => {
const response = await POST(validRequest());
expect(response.status).toBe(204);
expect(databaseState.inserts).toEqual(expect.arrayContaining([
expect.objectContaining({ requestId: "8dd9dbdc-020a-4077-983c-77747522de8f", serverId: "velocity-main" }),
expect.objectContaining({
type: "games.minecraft.account-manager.game.player.connected",
subject: "minecraft-account/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
actorUserId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
}),
]));
});
it("rejects replayed request IDs", async () => {
databaseState.replay = true;
const response = await POST(validRequest());
expect(response.status).toBe(409);
await expect(response.json()).resolves.toMatchObject({
type: "urn:error:replayed-velocity-connection-request",
status: 409,
});
});
it("does not record an event for an unknown account", async () => {
databaseState.account = null;
const response = await POST(validRequest());
expect(response.status).toBe(404);
expect(databaseState.inserts).toHaveLength(1);
});
});
@@ -0,0 +1,142 @@
import { randomUUID } from "node:crypto";
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
import { problemDetails, velocityConnectionRequestSchema } from "@minecraft-account-manager/contracts";
import { events, minecraftAccounts, pluginCredentials, pluginRequests } from "@minecraft-account-manager/database";
import { and, eq, isNull, lt } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/database";
import { isUniqueConstraintViolation } from "@/lib/database-errors";
import { logger } from "@/lib/logger";
import { problemInstance, problemResponse } from "@/lib/problem-response";
const MAX_CLOCK_SKEW_MS = 45_000;
function methodNotAllowed(request: Request) {
const response = problemResponse(problemDetails(
"urn:error:method-not-allowed",
"Method not allowed",
405,
"This endpoint only accepts POST requests.",
problemInstance(request),
));
response.headers.set("allow", "POST");
return response;
}
export const GET = methodNotAllowed;
export const PUT = methodNotAllowed;
export const PATCH = methodNotAllowed;
export const DELETE = methodNotAllowed;
export async function POST(request: Request) {
const instance = problemInstance(request);
const authorization = request.headers.get("authorization") ?? "";
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
if (!token) return problemResponse(problemDetails(
"urn:error:unauthorized",
"Unauthorized",
401,
"A valid Velocity server credential is required.",
instance,
));
const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
if (mediaType !== "application/json") return problemResponse(problemDetails(
"urn:error:unsupported-media-type",
"Unsupported media type",
415,
"Velocity connection reports must use application/json.",
instance,
));
const parsed = velocityConnectionRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return problemResponse(problemDetails(
"urn:error:invalid-velocity-connection-request",
"Invalid Velocity connection report",
400,
"The request body does not match the required Velocity connection contract.",
instance,
{ issues: parsed.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message, code: issue.code })) },
));
const input = parsed.data;
const occurredAt = new Date(input.occurredAt);
if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) return problemResponse(problemDetails(
"urn:error:expired-velocity-connection-request",
"Expired Velocity connection report",
401,
"The request timestamp is outside the allowed clock-skew window.",
instance,
));
const [credential] = await db
.select({ secretHash: pluginCredentials.secretHash })
.from(pluginCredentials)
.where(and(eq(pluginCredentials.serverId, input.serverId), isNull(pluginCredentials.revokedAt)))
.limit(1);
if (!credential || !verifyHashedToken(token, credential.secretHash)) return problemResponse(problemDetails(
"urn:error:unauthorized",
"Unauthorized",
401,
"The Velocity server credential is invalid or revoked.",
instance,
));
try {
const recorded = await db.transaction(async (tx) => {
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
await tx.insert(pluginRequests).values({
requestId: input.requestId,
serverId: input.serverId,
receivedAt: new Date(),
expiresAt: new Date(Date.now() + 5 * 60_000),
});
const [account] = await tx
.select({ id: minecraftAccounts.id, userId: minecraftAccounts.userId })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.minecraftUuid, input.minecraftUuid), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!account) return false;
await tx.insert(events).values({
id: randomUUID(),
source: `/velocity/${input.serverId}`,
type: "games.minecraft.account-manager.game.player.connected",
subject: `minecraft-account/${account.id}`,
time: occurredAt,
actorUserId: account.userId,
correlationId: input.requestId,
data: {
username: input.username,
minecraftUuid: input.minecraftUuid,
serverId: input.serverId,
},
});
return true;
});
if (!recorded) return problemResponse(problemDetails(
"urn:error:unknown-minecraft-account",
"Unknown Minecraft account",
404,
"The connected Minecraft account is no longer registered.",
instance,
));
return new NextResponse(null, { status: 204 });
} catch (error) {
if (isUniqueConstraintViolation(error, "plugin_requests_pkey")) return problemResponse(problemDetails(
"urn:error:replayed-velocity-connection-request",
"Velocity request replayed",
409,
"This Velocity request ID has already been processed.",
instance,
));
logger.error({ err: error, event: "velocity.connection_report_failed" }, "Failed to record a confirmed Velocity connection");
return problemResponse(problemDetails(
"urn:error:service-unavailable",
"Service unavailable",
503,
"The connection report could not be recorded.",
instance,
));
}
}
+38 -1
View File
@@ -1,3 +1,4 @@
@import "leaflet/dist/leaflet.css";
@import "tailwindcss";
@theme inline {
@@ -19,7 +20,7 @@
--ink: #171916;
--muted: #57594f;
--line: #9e9a88;
--accent: #bc3f24;
--accent: #a32f1b;
--signal: #b5d452;
--shadow: #262a23;
}
@@ -37,6 +38,42 @@ body {
background: var(--canvas);
}
:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
.skip-link {
position: fixed;
left: 1rem;
top: 1rem;
z-index: 100;
transform: translateY(-200%);
background: var(--ink);
color: var(--panel);
padding: 0.75rem 1rem;
font-weight: 700;
}
.skip-link:focus {
transform: translateY(0);
}
svg a:hover .map-marker,
svg a:focus .map-marker {
stroke: var(--ink);
stroke-width: 6px;
}
.map-marker-tooltip {
opacity: 0;
}
.map-marker-link:hover .map-marker-tooltip,
.map-marker-link:focus .map-marker-tooltip {
opacity: 1;
}
::selection {
background: var(--accent);
color: var(--panel);
+2 -1
View File
@@ -12,7 +12,8 @@ export default function RootLayout({ children }: Readonly<{ children: ReactNode
return (
<html lang="en">
<body className="flex min-h-screen flex-col">
<div className="flex-1">{children}</div>
<a className="skip-link" href="#main-content">Skip to main content</a>
<div className="flex-1" id="main-content" tabIndex={-1}>{children}</div>
<SiteFooter />
</body>
</html>
@@ -0,0 +1,82 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import type { UserMapLocation } from "./user-world-map";
export function MapViewToggle({ locations, children }: { locations: UserMapLocation[]; children: ReactNode }) {
const [view, setView] = useState<"overview" | "interactive">("overview");
return (
<div className="mt-6">
<div aria-label="Map view" className="flex flex-wrap gap-2" role="group">
<button aria-controls="map-overview-panel" aria-pressed={view === "overview"} className={`border px-4 py-2 font-mono text-[10px] font-bold uppercase ${view === "overview" ? "border-ink bg-ink text-canvas" : "border-line"}`} id="map-overview-tab" onClick={() => setView("overview")} type="button">World overview</button>
<button aria-controls="map-interactive-panel" aria-pressed={view === "interactive"} className={`border px-4 py-2 font-mono text-[10px] font-bold uppercase ${view === "interactive" ? "border-ink bg-ink text-canvas" : "border-line"}`} id="map-interactive-tab" onClick={() => setView("interactive")} type="button">Interactive OpenStreetMap</button>
</div>
<p className="mt-2 max-w-2xl text-[10px] leading-4 text-muted">Selecting the interactive view requests map tiles from OpenStreetMap, which receives your IP address, the portal origin, and the geographic area being viewed.</p>
<div aria-labelledby="map-overview-tab" hidden={view !== "overview"} id="map-overview-panel" role="region">{children}</div>
<div aria-labelledby="map-interactive-tab" hidden={view !== "interactive"} id="map-interactive-panel" role="region">
{view === "interactive" && <InteractiveMap locations={locations} />}
</div>
</div>
);
}
function InteractiveMap({ locations }: { locations: UserMapLocation[] }) {
const container = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!container.current) return;
let cancelled = false;
let cleanup = () => {};
void import("leaflet").then((leaflet) => {
if (cancelled || !container.current) return;
const map = leaflet.map(container.current, { minZoom: 1, worldCopyJump: true }).setView([20, 0], 2);
leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
maxZoom: 19,
referrerPolicy: "strict-origin-when-cross-origin",
}).addTo(map);
const bounds: [number, number][] = [];
for (const user of locations) {
const marker = leaflet.circleMarker([user.latitude, user.longitude], {
radius: 8,
color: "#eee8d8",
weight: 3,
fillColor: "#a32f1b",
fillOpacity: 1,
}).addTo(map);
const tooltip = document.createElement("span");
tooltip.textContent = `${user.nickname} · ${user.location}`;
marker.bindTooltip(tooltip, { direction: "top" });
const userPath = `/admin/users/${user.userId}`;
marker.on("click", () => window.location.assign(userPath));
const element = marker.getElement();
element?.setAttribute("aria-label", `${user.nickname}, ${user.location}`);
element?.setAttribute("role", "link");
element?.setAttribute("tabindex", "0");
element?.addEventListener("focus", () => marker.openTooltip());
element?.addEventListener("blur", () => marker.closeTooltip());
element?.addEventListener("keydown", (event) => {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key === "Enter" || keyboardEvent.key === " ") {
keyboardEvent.preventDefault();
window.location.assign(userPath);
}
});
bounds.push([user.latitude, user.longitude]);
}
if (bounds.length) map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 });
cleanup = () => map.remove();
});
return () => {
cancelled = true;
cleanup();
};
}, [locations]);
return <div aria-label="Interactive map of latest approximate user locations" className="mt-3 h-[32rem] max-h-[70vh] min-h-80 border border-line" ref={container} role="region" />;
}
@@ -0,0 +1,20 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { NicknameNotice } from "./nickname-notice";
describe("NicknameNotice", () => {
it("announces a synchronized nickname and provides a dismissal action", () => {
const markup = renderToStaticMarkup(<NicknameNotice nickname="Dani · Steve" />);
expect(markup).toContain('role="status"');
expect(markup).toContain("We updated your Discord nickname to");
expect(markup).toContain("Dani · Steve");
expect(markup).toContain("Awesome, thanks!");
});
it("announces synchronization errors assertively", () => {
const markup = renderToStaticMarkup(<NicknameNotice error="Discord rejected the update." />);
expect(markup).toContain('role="alert"');
expect(markup).toContain("Discord rejected the update.");
});
});
@@ -0,0 +1,20 @@
export function NicknameNotice({ nickname, error }: { nickname?: string; error?: string }) {
if (!nickname && !error) return null;
return (
<section
aria-live={error ? "assertive" : "polite"}
className={`mt-8 border-l-2 bg-panel px-5 py-4 ${error ? "border-accent" : "border-signal"}`}
role={error ? "alert" : "status"}
>
<p className="text-sm leading-6">
{error ? error : <>We updated your Discord nickname to <strong>{nickname}</strong>.</>}
</p>
<form action="/account" className="mt-3" method="get">
<button className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" type="submit">
Awesome, thanks!
</button>
</form>
</section>
);
}
@@ -0,0 +1,47 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UserWorldMap } from "./user-world-map";
describe("UserWorldMap", () => {
it("renders an accessible linked marker, text fallback, and open-data attribution", () => {
const markup = renderToStaticMarkup(<UserWorldMap locations={[{
userId: "11111111-1111-4111-8111-111111111111",
name: "Dani",
discordUsername: "dani",
nickname: "Dani (Steve)",
latitude: 37.4056,
longitude: -122.0775,
location: "Mountain View, California, US",
classification: "clear",
source: "game",
observedAt: new Date("2026-08-01T12:00:00Z"),
}]} unavailableCount={2} />);
expect(markup).toContain('role="group"');
expect(markup).toContain('class="map-marker-target"');
expect(markup).toContain("Latest approximate location for registered users");
expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"');
expect(markup).toContain("Dani (Steve)");
expect(markup).toContain("Mountain View, California, US");
expect(markup).toContain("World overview");
expect(markup).toContain("Interactive OpenStreetMap");
expect(markup).toContain("OpenStreetMap, which receives your IP address");
expect(markup).toContain("map-marker-tooltip");
expect(markup).toContain('id="map-overview-panel"');
expect(markup).not.toContain("tile.openstreetmap.org");
expect(markup).toContain("Natural Earth, public domain");
expect(markup).toContain("2 without coordinates");
const countryPaths = [...markup.matchAll(/<path d="([^"]+)"/g)].map((match) => match[1] ?? "");
expect(countryPaths.length).toBeGreaterThan(100);
for (const path of countryPaths) {
const subpaths = path.split("M").slice(1);
for (const subpath of subpaths) {
const xCoordinates = [...subpath.matchAll(/(?:^|L)(-?\d+(?:\.\d+)?),/g)].map((match) => Number(match[1]));
for (let index = 1; index < xCoordinates.length; index += 1) {
expect(Math.abs(xCoordinates[index]! - xCoordinates[index - 1]!)).toBeLessThan(500);
}
}
}
});
});
@@ -0,0 +1,95 @@
import type { FeatureCollection } from "geojson";
import type { GeometryCollection, Topology } from "topojson-specification";
import { geoEquirectangular, geoPath } from "d3-geo";
import { feature } from "topojson-client";
import countriesTopologyJson from "world-atlas/countries-110m.json";
import Link from "next/link";
import { MapViewToggle } from "./map-view-toggle";
const WIDTH = 1_000;
const HEIGHT = 500;
const topology = countriesTopologyJson as unknown as Topology<{ countries: GeometryCollection }>;
const countries = feature(topology, topology.objects.countries) as FeatureCollection;
const projection = geoEquirectangular().fitExtent([[1, 1], [WIDTH - 1, HEIGHT - 1]], { type: "Sphere" });
const countryPath = geoPath(projection);
export interface UserMapLocation {
userId: string;
name: string;
discordUsername: string;
nickname: string;
latitude: number;
longitude: number;
location: string;
classification: string;
source: string;
observedAt: Date;
}
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
return (
<section className="mt-8 border border-line bg-panel p-5 shadow-[8px_8px_0_var(--color-shadow)] sm:p-7">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Latest known location</p>
<h2 className="mt-2 font-display text-3xl font-black uppercase">Community world</h2>
</div>
<p className="max-w-sm text-xs leading-5 text-muted">{locations.length} mapped · {unavailableCount} without coordinates. Locations are approximate IP intelligence, not precise device positions.</p>
</div>
<MapViewToggle locations={locations}>
<div className="mt-3 overflow-hidden border border-line bg-[#b9d4d1]">
<svg aria-labelledby="user-world-map-title user-world-map-description" className="h-auto w-full" role="group" viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
<title id="user-world-map-title">Latest approximate location for registered users</title>
<desc id="user-world-map-description">An open-data world map with one linked marker for every user whose latest geolocated observation has valid coordinates. A complete text list follows.</desc>
<rect fill="#b9d4d1" height={HEIGHT} width={WIDTH} />
<g aria-hidden="true" fill="var(--canvas)" stroke="var(--line)" strokeWidth="0.7">
{countries.features.map((country, index) => {
const path = countryPath(country);
return path ? <path d={path} key={country.id ?? index} /> : null;
})}
</g>
<g>
{locations.map((user) => {
const projected = projection([user.longitude, user.latitude]);
if (!projected) return null;
const x = Math.min(WIDTH - 14, Math.max(14, projected[0]));
const y = Math.min(HEIGHT - 14, Math.max(14, projected[1]));
const tooltipWidth = Math.min(260, Math.max(110, user.nickname.length * 8 + 24));
const tooltipX = Math.min(WIDTH - tooltipWidth - 4, Math.max(4, x - tooltipWidth / 2));
const tooltipY = y > 46 ? y - 38 : y + 18;
return (
<a aria-label={`${user.nickname}, ${user.location}, last seen ${user.observedAt.toISOString()}`} className="map-marker-link" href={`/admin/users/${user.userId}`} key={user.userId}>
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r="7" stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke">
<title>{user.nickname} · {user.location} · {user.classification}</title>
</circle>
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r="7" stroke="var(--panel)" strokeWidth="3" />
<g aria-hidden="true" className="map-marker-tooltip" pointerEvents="none">
<rect fill="var(--ink)" height="28" rx="2" width={tooltipWidth} x={tooltipX} y={tooltipY} />
<text dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" textAnchor="middle" x={tooltipX + tooltipWidth / 2} y={tooltipY + 14}>{user.nickname}</text>
</g>
</a>
);
})}
</g>
</svg>
</div>
</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">
<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>
<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>}
</tbody>
</table>
</div>
</details>
</section>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { fillDailySeries, mergeRiskActivity } from "./admin-metrics";
describe("admin dashboard metrics", () => {
it("fills missing UTC registration days with zero", () => {
expect(fillDailySeries(
[{ day: "2026-07-30", count: 2 }, { day: "2026-08-01", count: 1 }],
new Date("2026-08-01T22:00:00Z"),
3,
)).toEqual([
{ day: "2026-07-30", count: 2 },
{ day: "2026-07-31", count: 0 },
{ day: "2026-08-01", count: 1 },
]);
});
it("merges complete per-user VPN summaries with each user's latest observation", () => {
const latest = [
{ userId: "user-2", classification: "tor", observedAt: new Date("2026-08-01T11:00:00Z") },
{ userId: "user-1", classification: "proxy", observedAt: new Date("2026-08-01T12:00:00Z") },
];
const summaries = [
{ userId: "user-1", count: 2000, classifications: ["proxy", "vpn"], sources: ["game", "web"] },
{ userId: "user-2", count: 1, classifications: ["tor"], sources: ["web"] },
];
expect(mergeRiskActivity(latest, summaries)).toEqual([
expect.objectContaining({ userId: "user-1", count: 2000, classification: "proxy", classifications: ["proxy", "vpn"], sources: ["game", "web"] }),
expect.objectContaining({ userId: "user-2", count: 1, classification: "tor" }),
]);
});
});
+28
View File
@@ -0,0 +1,28 @@
export interface DailyCount {
day: string;
count: number;
}
export function mergeRiskActivity<
T extends { userId: string; observedAt: Date },
S extends { userId: string | null },
>(latestRows: T[], summaryRows: S[]) {
const summaries = new Map(summaryRows.flatMap((summary) => summary.userId ? [[summary.userId, summary] as const] : []));
return latestRows
.flatMap((activity) => {
const summary = summaries.get(activity.userId);
return summary ? [{ ...activity, ...summary }] : [];
})
.sort((left, right) => right.observedAt.getTime() - left.observedAt.getTime());
}
export function fillDailySeries(rows: DailyCount[], end: Date, days: number) {
const counts = new Map(rows.map((row) => [row.day, Number(row.count)]));
const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()));
return Array.from({ length: days }, (_, index) => {
const date = new Date(endDay);
date.setUTCDate(endDay.getUTCDate() - (days - index - 1));
const day = date.toISOString().slice(0, 10);
return { day, count: counts.get(day) ?? 0 };
});
}
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { hasDiscordNicknameConfirmation } from "./dashboard-change-confirmation";
describe("dashboard identity change confirmation", () => {
it("accepts only the explicit Discord nickname confirmation value", () => {
expect(hasDiscordNicknameConfirmation(new FormData())).toBe(false);
const declined = new FormData();
declined.set("confirmDiscordNickname", "no");
expect(hasDiscordNicknameConfirmation(declined)).toBe(false);
const confirmed = new FormData();
confirmed.set("confirmDiscordNickname", "yes");
expect(hasDiscordNicknameConfirmation(confirmed)).toBe(true);
});
});
@@ -1,3 +0,0 @@
export function hasDiscordNicknameConfirmation(formData: FormData) {
return formData.get("confirmDiscordNickname") === "yes";
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { eventCategory, normalizeSelectedEventTypes } from "./event-filters";
describe("event filters", () => {
it("accepts only available event types and removes duplicates", () => {
expect(normalizeSelectedEventTypes(["login.allowed", "unknown", "login.allowed"], ["login.allowed", "group.updated"]))
.toEqual(["login.allowed"]);
});
it("classifies events into operator-friendly views", () => {
expect(eventCategory("games.minecraft.account-manager.group.deleted")).toBe("groups");
expect(eventCategory("games.minecraft.account-manager.game.login.denied")).toBe("admission");
expect(eventCategory("games.minecraft.account-manager.game.player.connected")).toBe("admission");
expect(eventCategory("games.minecraft.account-manager.network.vpn-blocked")).toBe("security");
expect(eventCategory("games.minecraft.account-manager.auth.magic-link.consumed")).toBe("security");
expect(eventCategory("games.minecraft.account-manager.discord.nickname.updated")).toBe("identity");
});
});
+20
View File
@@ -0,0 +1,20 @@
export const eventCategoryValues = ["all", "admission", "security", "identity", "groups", "operations"] as const;
export type EventCategory = (typeof eventCategoryValues)[number];
export function eventCategory(type: string): Exclude<EventCategory, "all"> {
if (type.includes(".group.")) return "groups";
if (type.includes(".network.") || type.includes(".auth.") || type.includes("authentication") || type.includes("replay")) return "security";
if (type.includes(".game.")) return "admission";
if (type.includes(".discord.") || type.includes(".user.") || type.includes("minecraft-account")) return "identity";
return "operations";
}
export function normalizeEventCategory(value: string | undefined): EventCategory {
return eventCategoryValues.includes(value as EventCategory) ? value as EventCategory : "all";
}
export function normalizeSelectedEventTypes(value: string | string[] | undefined, availableTypes: string[]) {
const requested = Array.isArray(value) ? value : value ? [value] : [];
const available = new Set(availableTypes);
return [...new Set(requested.filter((type) => available.has(type)))].slice(0, 20);
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { parseUserLocation, projectWorldPoint } from "./user-location-map";
describe("user location map", () => {
it("extracts a valid approximate location from cached IP intelligence", () => {
expect(parseUserLocation({
classification: "clear",
location: {
city: "Mountain View",
region: "California",
countryCode: "US",
latitude: 37.4056,
longitude: -122.0775,
},
})).toEqual({
latitude: 37.4056,
longitude: -122.0775,
label: "Mountain View, California, US",
});
});
it("rejects missing and out-of-range coordinates", () => {
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
});
it("projects longitude and latitude into an equirectangular SVG", () => {
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
});
});
+41
View File
@@ -0,0 +1,41 @@
type UnknownMap = Record<string, unknown>;
function objectValue(value: unknown): UnknownMap | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as UnknownMap
: 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);
return null;
}
export interface ParsedUserLocation {
latitude: number;
longitude: number;
label: string;
}
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
const intelligence = objectValue(value);
const location = objectValue(intelligence?.location);
if (!location) return null;
const latitude = coordinate(location.latitude);
const longitude = coordinate(location.longitude);
if (latitude === null || longitude === null || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
const label = [location.city, location.region, location.countryCode ?? location.country]
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
.join(", ");
return { latitude, longitude, label: label || "Approximate location unavailable" };
}
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
return {
x: ((longitude + 180) / 360) * width,
y: ((90 - latitude) / 180) * height,
};
}
+2 -1
View File
@@ -30,7 +30,8 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
* [US-014 — Receive standardized API errors](us-014-problem-details.md) - Application APIs return RFC 9457 Problem Details.
* [US-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls.
* [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) - Administrators assign users to groups that explicitly grant Minecraft access.
* [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.
# Tracking
+7
View File
@@ -1,7 +1,14 @@
# Design Update Log
## 2026-08-02
* **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.
## 2026-08-01
* **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard.
* **Refine**: Make group assignment exclusive with default fallback, add group deletion, automatically synchronize Discord nicknames with status notices, expose filterable event details, add an SSR operations dashboard, and improve accessibility.
* **Extend**: Add SoMC Portal branding, live Discord identity details, admin guild configuration visibility, and fail-closed group-based Minecraft admission.
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics.
+4 -4
View File
@@ -3,7 +3,7 @@ type: User Story
title: Manage linked accounts from the dashboard
description: Authenticated users maintain their profile and active Java Edition accounts.
tags: [player, dashboard, minecraft, profile]
timestamp: 2026-08-01T22:34:31Z
timestamp: 2026-08-01T23:10:59Z
story_id: US-005
status: verified
---
@@ -20,10 +20,10 @@ As a registered player, I want to manage my profile and linked Minecraft account
- [x] The user can soft-remove an active account.
- [x] The user can choose exactly one active primary account.
- [x] Removing a primary account promotes another active account when one exists.
- [x] Name and primary changes preview the expected Discord nickname and require explicit confirmation before either profile mutation occurs.
- [x] Name, primary, and account-removal changes automatically synchronize the expected Discord nickname and report the result.
- [x] The dashboard shows recent portal and game IP observations with classification and available location.
- [x] The dashboard shows the user's Discord display name, username, guild nickname, and immutable Discord ID.
- [x] The dashboard shows effective access groups and whether each group grants Minecraft access.
- [x] The dashboard shows the single effective access group and whether it grants Minecraft access.
- [x] The user can revoke the current session by signing out.
# Implementation
@@ -34,7 +34,7 @@ As a registered player, I want to manage my profile and linked Minecraft account
# Validation
Server actions verify the current session, constrain every account lookup by the authenticated user ID, and require the explicit Discord confirmation field before name or primary-account mutations. Confirmation parsing is covered by [`apps/web/src/lib/dashboard-change-confirmation.test.ts`](../apps/web/src/lib/dashboard-change-confirmation.test.ts).
Server actions verify the current session and constrain every account lookup by the authenticated user ID. Nickname result announcements are covered by [`apps/web/src/components/nickname-notice.test.tsx`](../apps/web/src/components/nickname-notice.test.tsx).
# Related Stories
+6 -4
View File
@@ -3,7 +3,7 @@ type: User Story
title: Keep Discord nicknames synchronized
description: Preferred names and primary Minecraft usernames determine community guild nicknames.
tags: [player, admin, discord, identity]
timestamp: 2026-08-01T18:43:58Z
timestamp: 2026-08-01T23:10:59Z
story_id: US-006
status: verified
---
@@ -16,10 +16,11 @@ As a community member, I want my Discord nickname to reflect my preferred name a
- [x] Given a preferred name and primary account, then the nickname format is `First name (MinecraftUsername)`.
- [x] Given Discord's 32-character limit, then the preferred-name portion is shortened while preserving the Minecraft username.
- [x] Given no remaining Minecraft account, then administrative synchronization falls back to the preferred name.
- [x] User name and primary changes display the proposed nickname before confirmation.
- [x] Given no remaining Minecraft account, then synchronization uses `First name (TBD)`.
- [x] User name, first-account, primary, and account-removal changes synchronize the nickname automatically without a second confirmation step.
- [x] Successful synchronization shows the exact new nickname in a dismissible status notice.
- [x] Discord failures show an assertive error notice without falsely claiming synchronization completed.
- [x] Administrator name, primary, and primary-removal operations synchronize the nickname automatically.
- [x] Discord failures are reported without falsely claiming the requested profile change completed.
- [x] A protected administrative retry action can synchronize the current desired nickname.
# Implementation
@@ -27,6 +28,7 @@ As a community member, I want my Discord nickname to reflect my preferred name a
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
- [`apps/web/src/components/nickname-notice.tsx`](../apps/web/src/components/nickname-notice.tsx)
# Validation
+2 -1
View File
@@ -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-01T18:43:58Z
timestamp: 2026-08-02T00:12:32Z
story_id: US-008
status: verified
---
@@ -21,6 +21,7 @@ As an operator, I want account additions blocked from anonymized networks, so th
- [x] Blocked users receive a clear recovery message without provider internals.
- [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.
# Implementation
+5 -2
View File
@@ -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-01T22:34:31Z
timestamp: 2026-08-02T00:12:32Z
story_id: US-009
status: verified
---
@@ -22,14 +22,17 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
- [x] Username fallback applies only when the stored account has no UUID.
- [x] Successful fallback backfills UUID and canonical username.
- [x] Changed usernames are persisted and audited.
- [x] Registered players are allowed only when at least one assigned group has access enabled.
- [x] Registered players are allowed only when their single effective group has access enabled; explicit assignments override the default group.
- [x] Unknown players, group-disabled players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance.
- [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.
# Implementation
- [`plugins/velocity`](../plugins/velocity)
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
- [`apps/web/src/app/api/velocity/connection/route.ts`](../apps/web/src/app/api/velocity/connection/route.ts)
- [`packages/contracts/src/index.ts`](../packages/contracts/src/index.ts)
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
+5 -3
View File
@@ -3,7 +3,7 @@ type: User Story
title: Preserve a CloudEvents-style audit trail
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
tags: [audit, cloudevents, security, events]
timestamp: 2026-08-01T18:43:58Z
timestamp: 2026-08-02T00:12:32Z
story_id: US-010
status: verified
---
@@ -16,10 +16,11 @@ As an operator, I want security and identity activity recorded consistently, so
- [x] Events preserve CloudEvents-style ID, specification version, source, type, subject, time, content type, and JSON data.
- [x] Events can include user actor, IP address, and correlation ID.
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, and game decisions are recorded.
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, game decisions, and confirmed proxy connections are recorded.
- [x] Username changes learned from Velocity create their own event.
- [x] Administrative actions include the acting SSO identity in event data.
- [x] Events can be inspected globally and from an individual admin user view.
- [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view.
- [x] Every listed event links to a detail page showing its complete CloudEvents envelope and formatted JSON data.
- [x] `published_at` reserves an outbox path for future Kafka publishing.
# Implementation
@@ -28,6 +29,7 @@ As an operator, I want security and identity activity recorded consistently, so
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
- [`apps/web/src/lib/audit.ts`](../apps/web/src/lib/audit.ts)
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
- [`apps/web/src/app/admin/(console)/events/[eventId]/page.tsx`](../apps/web/src/app/admin/%28console%29/events/%5BeventId%5D/page.tsx)
# Validation
+4 -1
View File
@@ -3,7 +3,7 @@ type: User Story
title: Deploy and operate the platform securely
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
tags: [operations, security, database, deployment]
timestamp: 2026-08-01T21:37:26Z
timestamp: 2026-08-01T23:10:59Z
story_id: US-015
status: verified
---
@@ -21,6 +21,8 @@ As a platform operator, I want reproducible deployment and security controls, so
- [x] The Velocity Gradle wrapper produces a tested shaded JAR.
- [x] Environment examples document database, Keycloak, Discord, trusted proxy, and ProxyCheck settings without secrets.
- [x] The web application sets CSP, framing, MIME, referrer, and permissions headers.
- [x] Database-backed user and administrator pages render as dynamic React Server Components with server-side data access.
- [x] Core pages provide keyboard focus indication, a skip link, labelled controls, table semantics, live status messaging, sufficient text contrast, and reduced-motion support.
- [x] The web runtime provides a dependency-free health endpoint for orchestration probes.
- [x] Web and Discord bot runtimes emit structured Pino logs with credential-field redaction and safe operational context.
- [x] npm dependency audit and Semgrep security review complete without findings at the last verified change.
@@ -35,6 +37,7 @@ As a platform operator, I want reproducible deployment and security controls, so
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
- [`apps/web/next.config.ts`](../apps/web/next.config.ts)
- [`packages/logging/src/index.ts`](../packages/logging/src/index.ts)
- [`docs/accessibility.md`](../docs/accessibility.md)
# Validation
+10 -8
View File
@@ -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-01T22:36:20Z
timestamp: 2026-08-01T23:10:59Z
story_id: US-017
status: verified
---
@@ -14,20 +14,22 @@ As an administrator, I want to organize registered users into access groups, so
# Acceptance Criteria
- [x] Every registered user implicitly belongs to the protected `everyone` group.
- [x] The `everyone` group is created with Minecraft access disabled.
- [x] Administrators can create groups with access disabled by default.
- [x] Administrators can add and remove users from non-default groups.
- [x] A registered user can have at most one explicit group assignment.
- [x] Users without an explicit assignment fall back to the protected `everyone` group.
- [x] The `everyone` group remains created with Minecraft access disabled.
- [x] Administrators can create groups with access disabled by default and move users between groups.
- [x] Administrators can enable or disable Minecraft admission for each group.
- [x] A registered player is admitted when any assigned group has access enabled.
- [x] A registered player is denied when none of their groups has access enabled.
- [x] Admission follows only the user's effective group; default and explicit-group access are never combined.
- [x] Administrators can delete non-default groups, returning affected users to `everyone`.
- [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 effective group assignments.
- [x] Users and administrators can inspect the user's single effective group assignment.
# 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)
- [`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)
+50
View File
@@ -0,0 +1,50 @@
---
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-02T00:12:32Z
story_id: US-018
status: verified
---
# User Story
As an administrator, I want an operational dashboard of account and game activity, so that I can understand community growth and quickly investigate access risks.
# Acceptance Criteria
- [x] The administrator landing page is a dashboard rather than a settings form.
- [x] A server-rendered Natural Earth overview plots each user's latest observation with valid approximate coordinates.
- [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview.
- [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution.
- [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent.
- [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.
- [x] The dashboard shows login denials from the previous 24 hours.
- [x] Recent VPN, proxy, and Tor observations use enriched ProxyCheck classifications, collapse repeated rows per user, and show counts, sources, and latest activity.
- [x] The graph includes an accessible title, description, point labels, and textual values.
- [x] Dashboard queries and initial rendering execute server-side; only the opt-in pan-and-zoom map hydrates client-side.
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
# Implementation
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
- [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx)
- [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts)
- [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx)
- [`apps/web/src/components/map-view-toggle.tsx`](../apps/web/src/components/map-view-toggle.tsx)
- [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.ts)
# 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, projection, linked markers, 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
- [Preserve a CloudEvents-style audit trail](us-010-audit-events.md)
- [Deploy and operate the platform securely](us-015-platform-operations.md)
- [Block anonymized account additions](us-008-vpn-blocking.md)
+36
View File
@@ -0,0 +1,36 @@
# Accessibility review
Review date: 2026-08-02
## Scope
Player account management, administrator navigation, dashboard metrics and chart, user records, group management, event filtering, event details, forms, tables, and status notifications.
## Implemented checks and improvements
- Added a keyboard-visible “Skip to main content” link and consistent high-visibility `:focus-visible` outlines.
- 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 `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.
- Added semantic `time` elements for audit and security activity timestamps.
- Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer.
- 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.
- 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.
- Allowed administrator navigation to wrap at narrow viewport widths instead of overflowing.
## Validation
- ESLint with the Next.js ruleset passes.
- Component rendering tests verify nickname status and error announcement roles and dismissal text.
- The production build passes and reports database-backed player and administrator pages as dynamic server-rendered routes.
- Color contrast was calculated for the canvas, panel, muted text, accent text, and signal combinations used by the interface.
## Follow-up
Authenticated browser automation is still recommended in CI with axe-core and a test Keycloak realm. It should cover keyboard order, zoom to 200%, reflow at 320 CSS pixels, and screen-reader announcements against a running production build.
+7 -3
View File
@@ -29,12 +29,16 @@ Every error response has media type `application/problem+json` and the shape:
| Type | Status | Meaning |
| --- | ---: | --- |
| `urn:error:invalid-velocity-access-request` | 400 | Request JSON does not satisfy the shared Velocity contract |
| `urn:error:invalid-velocity-access-request` | 400 | Access request JSON does not satisfy the shared Velocity contract |
| `urn:error:invalid-velocity-connection-request` | 400 | Confirmed-connection JSON does not satisfy the shared Velocity contract |
| `urn:error:unauthorized` | 401 | Velocity bearer credential is missing, invalid, or revoked |
| `urn:error:expired-velocity-access-request` | 401 | Request timestamp is outside the accepted clock-skew window |
| `urn:error:expired-velocity-access-request` | 401 | Access timestamp is outside the accepted clock-skew window |
| `urn:error:expired-velocity-connection-request` | 401 | Connection timestamp is outside the accepted clock-skew window |
| `urn:error:not-found` | 404 | Unknown application-owned API route |
| `urn:error:unknown-minecraft-account` | 404 | Connection telemetry references an inactive or unknown account |
| `urn:error:method-not-allowed` | 405 | The endpoint does not support the requested HTTP method |
| `urn:error:replayed-velocity-access-request` | 409 | Request ID was already processed |
| `urn:error:replayed-velocity-access-request` | 409 | Admission request ID was already processed |
| `urn:error:replayed-velocity-connection-request` | 409 | Confirmed-connection request ID was already processed |
| `urn:error:unsupported-media-type` | 415 | The request does not use `application/json` |
| `urn:error:service-unavailable` | 503 | A safe access decision could not be completed |
+3 -2
View File
@@ -4,7 +4,7 @@
### Web application
The Next.js application owns user onboarding, account management, admin configuration, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations.
The Next.js application owns user onboarding, account management, admin configuration and metrics, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. Database-backed portal and console pages are dynamic React Server Components: authentication, queries, filtering, dashboard aggregation, and the initial Natural Earth user-location map execute on the server and return rendered HTML. Administrators can opt into a hydrated Leaflet/OpenStreetMap view; OSM receives requests only for viewed map tiles, while user marker coordinates remain local to the browser.
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
@@ -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 decision is fail closed. Unknown players, invalid responses, expired requests, authentication failures, and unavailable API responses are denied with the configured registration message.
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.
## Trust boundaries
@@ -52,3 +52,4 @@ Events use reverse-DNS names beneath `games.minecraft.account-manager`, includin
- `games.minecraft.account-manager.network.vpn-blocked`
- `games.minecraft.account-manager.game.login.allowed`
- `games.minecraft.account-manager.game.login.denied`
- `games.minecraft.account-manager.game.player.connected`
+6 -4
View File
@@ -1,6 +1,6 @@
# Security review
Review date: 2026-08-01
Review date: 2026-08-02
## Scope
@@ -22,10 +22,12 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
- User mutations verify ownership server-side.
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
- Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
- 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 at least one enabled access group; the implicit `everyone` group starts disabled.
- Group and membership mutations re-check the Keycloak administrator role server-side and are audited.
- 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.
- 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.
- ORM-parameterized queries are used throughout.
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
+121 -1
View File
@@ -42,17 +42,24 @@
"@minecraft-account-manager/logging": "*",
"@minecraft-account-manager/minecraft": "*",
"@minecraft-account-manager/network": "*",
"d3-geo": "^3.1.1",
"drizzle-orm": "^0.45.1",
"leaflet": "^1.9.4",
"next": "^16.2.1",
"next-auth": "^4.24.13",
"react": "^19.2.3",
"react-dom": "^19.2.3"
"react-dom": "^19.2.3",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/d3-geo": "^3.1.1",
"@types/leaflet": "^1.9.22",
"@types/node": "^25.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1",
"tailwindcss": "^4.2.1",
@@ -2724,6 +2731,16 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2738,6 +2755,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2752,6 +2776,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.22",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "25.9.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
@@ -2781,6 +2815,27 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/topojson-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/topojson-specification": "*"
}
},
"node_modules/@types/topojson-specification": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -4084,6 +4139,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4129,6 +4190,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -5718,6 +5803,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
@@ -6332,6 +6426,12 @@
"node": ">=0.10"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -8337,6 +8437,20 @@
"node": ">=8.0"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -9261,6 +9375,12 @@
"node": ">=0.10.0"
}
},
"node_modules/world-atlas": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+2 -2
View File
@@ -111,8 +111,8 @@ export function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function enabledAccessGroup<T extends { accessEnabled: boolean }>(assignedGroups: T[]) {
return assignedGroups.find((group) => group.accessEnabled) ?? null;
export function resolveEffectiveGroup<T>(explicitGroup: T | null, defaultGroup: T | null) {
return explicitGroup ?? defaultGroup;
}
export function verifyHashedToken(providedToken: string, expectedHash: string) {
+15 -7
View File
@@ -1,12 +1,20 @@
import { describe, expect, it } from "vitest";
import { enabledAccessGroup } from "../src/index";
import { resolveEffectiveGroup } from "../src/index";
describe("group-based admission", () => {
it("denies default-off users and allows access when any assigned group is enabled", () => {
expect(enabledAccessGroup([{ name: "everyone", accessEnabled: false }])).toBeNull();
expect(enabledAccessGroup([
{ name: "everyone", accessEnabled: false },
{ name: "ops", accessEnabled: true },
])).toEqual({ name: "ops", accessEnabled: true });
const everyone = { name: "everyone", accessEnabled: false };
it("uses the default group only when a user has no explicit assignment", () => {
expect(resolveEffectiveGroup(null, everyone)).toEqual(everyone);
expect(resolveEffectiveGroup({ name: "limited", accessEnabled: true }, everyone)).toEqual({
name: "limited",
accessEnabled: true,
});
});
it("does not combine default and explicitly assigned group access", () => {
const enabledDefault = { name: "everyone", accessEnabled: true };
const limited = { name: "limited", accessEnabled: false };
expect(resolveEffectiveGroup(limited, enabledDefault)?.accessEnabled).toBe(false);
});
});
+10
View File
@@ -33,6 +33,16 @@ export const velocityAccessRequestSchema = z.object({
export type VelocityAccessRequest = z.infer<typeof velocityAccessRequestSchema>;
export const velocityConnectionRequestSchema = z.object({
requestId: z.uuid(),
serverId: z.string().min(1).max(100),
minecraftUuid: minecraftUuidSchema,
username: minecraftUsernameSchema,
occurredAt: isoDateTimeSchema,
});
export type VelocityConnectionRequest = z.infer<typeof velocityConnectionRequestSchema>;
export const velocityAccessResponseSchema = z.discriminatedUnion("allowed", [
z.object({
allowed: z.literal(true),
+14
View File
@@ -3,6 +3,7 @@ import {
cloudEventSchema,
velocityAccessRequestSchema,
velocityAccessResponseSchema,
velocityConnectionRequestSchema,
} from "../src/index";
describe("shared service contracts", () => {
@@ -37,6 +38,19 @@ describe("shared service contracts", () => {
).toThrow();
});
it("validates a confirmed Velocity connection report", () => {
const request = velocityConnectionRequestSchema.parse({
requestId: "8dd9dbdc-020a-4077-983c-77747522de8f",
serverId: "velocity-main",
minecraftUuid: "069a79f444e94726a5befca90e38aaf5",
username: "Notch",
occurredAt: "2026-03-06T12:00:01.000Z",
});
expect(request.username).toBe("Notch");
expect(() => velocityConnectionRequestSchema.parse({ ...request, username: "bad name" })).toThrow();
});
it("only returns explicit allow or deny decisions to Velocity", () => {
expect(
velocityAccessResponseSchema.parse({
@@ -0,0 +1,11 @@
DROP INDEX "user_group_memberships_user_group_uidx";--> statement-breakpoint
DELETE FROM "user_group_memberships"
WHERE ctid IN (
SELECT ctid
FROM (
SELECT ctid, row_number() OVER (PARTITION BY "user_id" ORDER BY "created_at" DESC, "group_id") AS assignment_rank
FROM "user_group_memberships"
) ranked_assignments
WHERE assignment_rank > 1
);--> statement-breakpoint
CREATE UNIQUE INDEX "user_group_memberships_user_uidx" ON "user_group_memberships" USING btree ("user_id");
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,13 @@
"when": 1785623198008,
"tag": "0002_simple_queen_noir",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1785625186545,
"tag": "0003_smiling_silver_samurai",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -91,7 +91,7 @@ export const userGroupMemberships = pgTable(
createdAt: createdAt(),
},
(table) => [
uniqueIndex("user_group_memberships_user_group_uidx").on(table.userId, table.groupId),
uniqueIndex("user_group_memberships_user_uidx").on(table.userId),
index("user_group_memberships_group_idx").on(table.groupId),
],
);
+1 -1
View File
@@ -43,7 +43,7 @@ export function formatManagedDiscordNickname(
minecraftUsername: string | null,
) {
if (minecraftUsername) return formatDiscordNickname(firstName, minecraftUsername);
return [...firstName.trim()].slice(0, DISCORD_NICKNAME_LIMIT).join("").trimEnd();
return formatDiscordNickname(firstName, "TBD");
}
export interface DiscordGuildIdentity {
+4 -3
View File
@@ -28,8 +28,9 @@ describe("Java Edition profiles", () => {
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
});
it("falls back to the user's name when an admin removes their final account", () => {
expect(formatManagedDiscordNickname("Alexandria Catherine", null)).toBe("Alexandria Catherine");
expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null)).toHaveLength(32);
it("marks the Discord nickname TBD when the user has no Minecraft account", () => {
expect(formatManagedDiscordNickname("Alexandria Catherine", null)).toBe("Alexandria Catherine (TBD)");
expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null).length).toBeLessThanOrEqual(32);
expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null)).toMatch(/ \(TBD\)$/);
});
});
+1 -1
View File
@@ -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.
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.
## Download or build
@@ -59,6 +59,43 @@ final class AccountManagerClient {
}
}
boolean reportConnected(UUID minecraftUuid, String username) {
String compactUuid = minecraftUuid.toString().replace("-", "").toLowerCase();
ConnectionRequest payload = new ConnectionRequest(
UUID.randomUUID().toString(),
config.serverId(),
compactUuid,
username,
Instant.now().toString()
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(config.apiUrl() + "/api/velocity/connection"))
.timeout(config.timeout())
.header("Authorization", "Bearer " + config.apiToken())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() == 204;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return false;
} catch (IOException | RuntimeException exception) {
return false;
}
}
private record ConnectionRequest(
String requestId,
String serverId,
String minecraftUuid,
String username,
String occurredAt
) {}
private record AccessRequest(
String requestId,
String serverId,
@@ -5,9 +5,11 @@ import com.velocitypowered.api.event.EventTask;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.ResultedEvent;
import com.velocitypowered.api.event.connection.LoginEvent;
import com.velocitypowered.api.event.connection.PostLoginEvent;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import java.io.IOException;
import java.nio.file.Path;
import net.kyori.adventure.text.Component;
@@ -22,13 +24,15 @@ import org.slf4j.Logger;
public final class MinecraftAccountManagerPlugin {
private final Logger logger;
private final Path dataDirectory;
private final ProxyServer proxyServer;
private volatile AccountManagerClient accountManagerClient;
private volatile String fallbackMessage = "Please register your Minecraft account in Discord before joining.";
@Inject
public MinecraftAccountManagerPlugin(Logger logger, @DataDirectory Path dataDirectory) {
public MinecraftAccountManagerPlugin(Logger logger, @DataDirectory Path dataDirectory, ProxyServer proxyServer) {
this.logger = logger;
this.dataDirectory = dataDirectory;
this.proxyServer = proxyServer;
}
@Subscribe
@@ -66,4 +70,19 @@ public final class MinecraftAccountManagerPlugin {
}
});
}
@Subscribe
public void onPostLogin(PostLoginEvent event) {
proxyServer.getScheduler().buildTask(this, () -> {
AccountManagerClient client = accountManagerClient;
if (client == null) return;
boolean recorded = client.reportConnected(
event.getPlayer().getUniqueId(),
event.getPlayer().getUsername()
);
if (!recorded) {
logger.warn("Could not report confirmed Minecraft connection for {} ({})", event.getPlayer().getUsername(), event.getPlayer().getUniqueId());
}
}).schedule();
}
}
@@ -2,9 +2,15 @@ package games.dmg.accountmanager;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class AccountManagerClientTest {
@@ -27,4 +33,55 @@ class AccountManagerClientTest {
assertFalse(decision.allowed());
assertEquals("Register through Discord.", decision.message());
}
@Test
void reportsConfirmedConnectionsToTheAuthenticatedEndpoint() throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
AtomicReference<String> body = new AtomicReference<>();
AtomicReference<String> authorization = new AtomicReference<>();
server.createContext("/api/velocity/connection", exchange -> {
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
exchange.sendResponseHeaders(204, -1);
exchange.close();
});
server.start();
try {
PluginConfig config = new PluginConfig(
"http://127.0.0.1:" + server.getAddress().getPort(),
"velocity-test",
"test-token",
Duration.ofSeconds(2),
"Register through Discord."
);
assertTrue(new AccountManagerClient(config).reportConnected(
UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5"),
"Notch"
));
assertEquals("Bearer test-token", authorization.get());
assertTrue(body.get().contains("\"minecraftUuid\":\"069a79f444e94726a5befca90e38aaf5\""));
assertTrue(body.get().contains("\"username\":\"Notch\""));
} finally {
server.stop(0);
}
}
@Test
void connectionReportingIsBestEffortWhenTheApiCannotBeReached() {
PluginConfig config = new PluginConfig(
"http://127.0.0.1:1",
"velocity-test",
"test-token",
Duration.ofMillis(100),
"Register through Discord."
);
boolean recorded = new AccountManagerClient(config).reportConnected(
UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5"),
"Notch"
);
assertFalse(recorded);
}
}